// How the pages put things into words. import type { LiveStep } from './live.ts'; import { zone } from './zone.svelte.ts'; 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']; const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const LONG_MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; /** How each zone's clock is read, made once per zone. */ const clocks = new Map(); const clockIn = (tz: string) => { let f = clocks.get(tz); if (!f) { const n = 'numeric'; f = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: n, month: n, day: n, hour: n, minute: n, hourCycle: 'h23' }); clocks.set(tz, f); } return f; }; /** `t` on the clock where it is read (see zone.svelte.ts): the viewer's zone, or UTC until it is known. */ function on(t: Date) { const part: Record = {}; for (const p of clockIn(zone.tz ?? 'UTC').formatToParts(t)) part[p.type] = Number(p.value); const m = part.month - 1; const w = new Date(Date.UTC(part.year, m, part.day)).getUTCDay(); return { y: part.year, m, d: part.day, w, h: part.hour, min: part.minute }; } /** `iso` on the clock where it is read, or null if it is no time. */ function when(iso: string) { const t = new Date(iso); return isNaN(+t) ? null : on(t); } const two = (n: number) => String(n).padStart(2, '0'); /** `n` with its thousands apart, as `1,640`, the same wherever it is drawn. */ export function number(n: number): string { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } /** `2026-10-20T…` as `Oct 20, 2026`; anything else as it is. */ export function date(iso: string): string { const t = when(iso); return t ? `${MONTHS[t.m]} ${t.d}, ${t.y}` : iso; } /** `2026-10-09T…` as `Friday, October 9`, or as `Today` or `Yesterday`: a heading. */ export function weekday(iso: string): string { const t = when(iso); if (!t) return iso; const back = daysBack(iso); if (back === 0) return 'Today'; if (back === 1) return 'Yesterday'; return `${WEEKDAYS[t.w]}, ${LONG_MONTHS[t.m]} ${t.d}`; } /** * How long before now `iso` was, as `just now`, `3 minutes ago`, `2 hours * ago`, `yesterday`, `4 days ago`, `last week` or `3 weeks ago`, and from a * month back its date, after `before`: as a sentence says when. */ export function ago(iso: string, before = ''): string { return relative(iso, false) ?? `${before}${date(iso)}`; } /** * When a queued pull `q` should land, as it is `now`, by `typical`, how * long its merge queue takes (`dev.knotless.defs#queueTypicalView`): in a * batch that started at `batchStartedAt`, a batch after that; in none, * once its pre-flights would pass, or now if they have, and a batch after * that or after the batch ahead of it, started at `aheadStartedAt`, * whichever is later. Null while landing, or with nothing to go by. */ export function landsAt( q: { stage?: string; checks?: { status: string; startedAt: string }[] }, typical: { batchSeconds: number; preflightSeconds: number } | undefined, batchStartedAt: string | undefined, aheadStartedAt: string | undefined, now: number ): number | null { if (!typical || !q.stage || q.stage === 'landing' || q.stage === 'failed') return null; const batch = typical.batchSeconds * 1000; if (q.stage === 'checking') return batchStartedAt ? Math.max(Date.parse(batchStartedAt) + batch, now) : null; const going = (q.checks ?? []) .filter((c) => c.status === 'running' || c.status === 'queued') .map((c) => Date.parse(c.startedAt)); const flight = typical.preflightSeconds * 1000; const ready = q.stage === 'ready' ? now : going.length ? Math.max(Math.min(...going) + flight, now) : now + flight; const ahead = aheadStartedAt ? Date.parse(aheadStartedAt) + batch : ready; return Math.max(ready, ahead) + batch; } /** When `at`, a time to come, is from now, as a queued pull lands: `lands in about 8 minutes`, `lands in about 2 hours`, or `lands any minute now` once it is a minute off or past. */ export function landsIn(at: number): string { const minutes = Math.round((at - zone.now) / 60_000); if (minutes <= 1) return 'lands any minute now'; if (minutes < 60) return `lands in about ${plural(minutes, 'minute', 'minutes')}`; return `lands in about ${plural(Math.round(minutes / 60), 'hour', 'hours')}`; } /** * How long before now `iso` was, as `ago` puts it, and from a month back as * `last month`, `5 months ago`, `last year` or `2 years ago`: as a column * of them, a directory's, says how old each is. */ export function age(iso: string): string { return relative(iso, true) ?? date(iso); } /** How fresh `iso` is, from 1 (now) to 10 (three weeks back), as its age is coloured; null past that. */ export function heat(iso: string): number | null { const back = zone.now - Date.parse(iso); if (isNaN(back) || back >= HEAT_SPAN) return null; return Math.max(1, Math.ceil((back / HEAT_SPAN) * 10)); } /** How long an age keeps any heat: about three weeks. */ const HEAT_SPAN = 21 * 24 * 60 * 60_000; /** `ago` and `age`'s words, months and years only when `far`; null past them, or for no time. */ function relative(iso: string, far: boolean): string | null { const t = Date.parse(iso); if (isNaN(t)) return null; const minutes = Math.floor((zone.now - t) / 60_000); if (minutes < 1) return 'just now'; if (minutes < 60) return `${plural(minutes, 'minute', 'minutes')} ago`; if (minutes < 24 * 60) return `${plural(Math.floor(minutes / 60), 'hour', 'hours')} ago`; const days = daysBack(iso) ?? 0; if (days <= 1) return 'yesterday'; if (days < 7) return `${days} days ago`; const weeks = Math.floor(days / 7); if (days < 30) return weeks === 1 ? 'last week' : `${weeks} weeks ago`; if (!far) return null; const months = Math.floor(days / 30); if (months < 12) return months === 1 ? 'last month' : `${months} months ago`; const years = Math.floor(days / 365) || 1; return years === 1 ? 'last year' : `${years} years ago`; } /** How many of the viewer's days before today `iso` fell on. */ /** How far back `iso` is, as notifications are put together: `Today`, `Yesterday`, `This week` or `Earlier`. */ export function since(iso: string): string { const back = daysBack(iso) ?? Infinity; return back <= 0 ? 'Today' : back === 1 ? 'Yesterday' : back < 7 ? 'This week' : 'Earlier'; } function daysBack(iso: string): number | null { const then = when(iso); if (!then) return null; const today = on(new Date(zone.now)); return Math.round( (Date.UTC(today.y, today.m, today.d) - Date.UTC(then.y, then.m, then.d)) / 86_400_000 ); } /** * When a commit was committed, or, from a server that does not say so, * when its author wrote it. */ export function whenCommitted(c: { date: string; committedAt?: string }): string { return c.committedAt ?? c.date; } /** The day `iso` falls on, as `2026-10-09`: what times are put together by. */ export function day(iso: string): string { const t = when(iso); return t ? `${t.y}-${two(t.m + 1)}-${two(t.d)}` : iso; } /** `iso`'s hour and minute, as `09:31`. */ export function clock(iso: string): string { const t = when(iso); return t ? `${two(t.h)}:${two(t.min)}` : iso; } /** `iso` in full, as `Oct 9, 2026, 09:31`, saying so when it is UTC's. */ export const moment = (iso: string) => when(iso) ? `${date(iso)}, ${clock(iso)}${zone.tz ? '' : ' UTC'}` : iso; /** A commit message's first line. */ export const subject = (message: string) => message.split('\n')[0]; /** The largest file `dev.knotless.getBlame` blames, in bytes. */ export const BLAME_MAX = 256 * 1024; /** Whether `path` names a Markdown file, which the pages show made into HTML, as knotless-serve knows one. */ export const isMarkdown = (path: string) => /\.(md|markdown|mdown|mkd)$/i.test(path); 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 link as a reader names it: its host and path, without the scheme, a `www.` or a trailing slash. */ export function siteLabel(url: string): string { try { const u = new URL(url); return (u.host.replace(/^www\./, '') + u.pathname + u.search).replace(/\/$/, ''); } catch { return url; } } /** 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 cells of a 5×5 pattern that `seed` picks, each as its column and * row, mirrored left to right as GitHub's identicons are: the same each * time, and seldom another's. */ export function identicon(seed: string): [number, number][] { let h = 0x811c9dc5; for (const b of encoder.encode(seed)) h = Math.imul(h ^ b, 0x01000193) >>> 0; const cells: [number, number][] = []; for (let i = 0; i < 15; i++) { if (!((h >>> i) & 1)) continue; const [x, y] = [Math.floor(i / 5), i % 5]; cells.push([x, y]); if (x < 2) cells.push([4 - x, y]); } return cells; } /** 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; } /** `text` on one line, cut at `max` characters, for whatever unfurls a link. */ export function summary(text: string, max: number): string { const flat = text.split(/\s+/).filter(Boolean).join(' '); const chars = [...flat]; return chars.length > max ? `${chars.slice(0, max).join('').trimEnd()}…` : flat; } /** * The author, collection and rkey of the record at `uri`, public * (`at:////`) or in a private repository's space * (`at:///space/////`). */ export function recordOf(uri: string): { author: string; collection: string; rkey: string } { const parts = uri.replace(/^at:\/\//, '').split('/'); const [author = '', collection = '', rkey = ''] = parts[1] === 'space' ? parts.slice(4) : parts; return { author, collection, rkey }; } /** The page of the check at `uri`, of the repository `repo` names. */ export function checkHref(repo: { handle: string; name: string }, uri: string): string { const { author, rkey } = recordOf(uri); return repoHref(repo.handle, repo.name, `/check/${author}/${rkey}`); } /** The page of the pull or issue at `uri`, on the repository at `repo`. */ export function itemHref(repo: string, uri: string): string { const { author, rkey } = recordOf(uri); return `${repo}/${kindOf(uri)}/${author}/${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) => recordOf(uri).collection === 'dev.knotless.pull' ? 'pull' : 'issue'; /** Where a set of checks stands as one: failing if any failed, else running if any runs, else queued if any waits for a runner. */ export function overall(statuses: string[]): string { if (statuses.some((s) => s === 'failure' || s === 'error')) return 'failure'; if (statuses.includes('running')) return 'running'; if (statuses.includes('queued')) return 'queued'; if (statuses.includes('cancelled')) return 'cancelled'; return 'success'; } /** How long `ms` milliseconds took, as `1m 23s`, `4s` or `0s`. */ export function took(ms: number): string { const s = Math.round(ms / 1000); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; } /** How long a run from `started` to `finished` took, if it has finished. */ export function ran(started: string, finished?: string | null): string | null { const ms = finished ? Date.parse(finished) - Date.parse(started) : NaN; return Number.isNaN(ms) ? null : took(ms); } /** A check's row, in words: a skip says only that, its note on the row's tooltip and its page. */ export function checkSaid(c: { status: string; startedAt: string; finishedAt?: string; error?: string; skipped?: string; }): string { if (c.skipped) return 'Skipped'; const took = ran(c.startedAt, c.finishedAt); return `${c.error ?? capitalized(c.status)}${took ? ` in ${took}` : ''}`; } /** What a pull's author asks, by its `state`, between their name and its base: still wanting it while it is open, and once it is merged or closed, by whoever did that, what they asked. */ export function mergeSaid(state: string): string { return state === 'open' ? ' wants to merge into ' : ' asked to merge into '; } /** How a check's row stands for its mark: a skip as skipped, whatever status it filed. */ export function standing(c: { status: string; skipped?: string }): string { return c.skipped ? 'skipped' : c.status; } /** `checks` in the order GitHub lists them: failed, running, waiting, cancelled, passed, then skipped, by workflow name within each. */ export function byStanding( checks: T[] ): T[] { const rank = (c: T) => ['failure', 'error', 'running', 'queued', 'cancelled', 'success', 'skipped'].indexOf( standing(c) ) >>> 0; return [...checks].sort((a, b) => rank(a) - rank(b) || a.workflow.localeCompare(b.workflow)); } /** * A check's log cut into what each of `steps` printed, by the `==> ` * line the runner writes before each. Lines before the first such line * are the end of the step before it, whose start the log's tail lost, or * of the last step if the tail holds none; before the first step's, they * go with it. */ export function stepLogs(log: string, steps: string[]): string[] { const out = steps.map(() => ''); const lines = log.split('\n'); const heads = steps.map((s) => `==> ${s}`); const first = heads.findIndex((h) => lines.includes(h)); let next = first === -1 ? steps.length : first; let at = Math.max(next - 1, 0); for (const line of lines) { if (next < steps.length && line === heads[next]) { at = next++; continue; } if (at < out.length) out[at] += `${line}\n`; } return out.map((text) => text.replace(/\n+$/, '')); } /** A running check's steps as its output shows them, each with what it printed: those that ended, then the one going, as its runner names it or by its `==> name` line past theirs; none goes after one that failed. */ export function runningSteps( text: string, ended: LiveStep[], named?: string ): { step: LiveStep; log: string }[] { const lines = text.split('\n'); let at = 0; for (let seen = 0; at < lines.length && seen < ended.length; at++) { if (lines[at] === `==> ${ended[seen].name}`) seen++; } const failed = ended.some((s) => s.status !== 'success'); const header = lines .slice(at) .find((line) => line.startsWith('==> ')) ?.slice(4); const going = failed ? undefined : (named ?? header); const steps = going ? [...ended, { name: going, status: 'running' }] : ended; const logs = stepLogs( text, steps.map((s) => s.name) ); return steps.map((step, i) => ({ step, log: logs[i] })); } /** * Why a pull was ejected from its merge queue, as the page says it: an * older runner ended the reason with a command to run, the one that reads * the failed checks' logs, which the page links to as the checks * themselves, or the one that approves it, which the review box beside it * does. */ export const ejectedWhy = (reason?: string | null) => (reason ?? 'no reason given') .replace(/[.;] [Tt]heir logs: `knotless run list [^`]*`.*$/s, '') .replace( /\. Anyone but its author who may write approves it with `knotless comment add .*$/s, '' ); /** Where the files of the repository `name` that `owner` has, at `rev`, download as one archive of `kind`. */ export const archiveHref = (owner: string, name: string, rev: string, kind: 'zip' | 'tar.gz') => repoHref(owner, name, `/archive/${urlPath(rev)}.${kind}`);