1// Small page behaviors: copy buttons, the wrap-lines checkbox, the find-file2// button for touch screens, and relative times.3(() => {4 const copy = async (text: string, button: HTMLElement): Promise<void> => {5 await navigator.clipboard.writeText(text);6 const was = button.textContent;7 button.textContent = 'copied';8 setTimeout(() => (button.textContent = was), 1200);9 };1011 document.addEventListener('click', (e) => {12 const el = (e.target as Element | null)?.closest<HTMLElement>('[data-copy], [data-copy-code], [data-finder]');13 if (!el) return;14 if (el.dataset.copy !== undefined) void copy(el.dataset.copy, el);15 else if (el.hasAttribute('data-copy-code')) {16 const lines = [...document.querySelectorAll('.code.blob .lc')].map((l) => l.textContent ?? '');17 void copy(lines.join('\n') + '\n', el);18 } else if (el.hasAttribute('data-finder')) {19 document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }));20 }21 });2223 // The wrap-lines checkbox (ui.checkbox, with data-wrap).24 document.addEventListener('change', (e) => {25 const el = e.target as HTMLInputElement;26 if (!el.matches?.('input[data-wrap]')) return;27 localStorage.setItem('forge.wrap', el.checked ? '1' : '0');28 wrap(el.checked);29 });3031 // Long lines wrap on narrow screens unless the reader turned it off, and on32 // wider ones if they turned it on. The CSS has the same default for pages33 // without JS; no-wrap marks an explicit off.34 const wrap = (on: boolean): void => {35 document.body.classList.toggle('wrap-lines', on);36 document.body.classList.toggle('no-wrap', !on);37 for (const b of document.querySelectorAll<HTMLInputElement>('input[data-wrap]')) b.checked = on;38 };39 const wrapped = (): boolean => {40 const v = localStorage.getItem('forge.wrap');41 return v ? v === '1' : matchMedia('(max-width: 720px)').matches;42 };4344 // <time> carries absolute UTC (lib/render.ts); say it relative to now, with45 // the full local time on hover. Static pages go stale, so this runs in the46 // browser, and again every minute. A time in its own column is compact47 // ("now", "5m", "5h", "1d", "3w", then "Sep 24", or "2025-03-01" from an48 // earlier year); one in a sentence (data-ago) reads as one ("just now",49 // "5m ago", then "Sep 24, 2025").50 const full = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });51 const day = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });52 const dayYear = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: 'numeric' });53 const two = (n: number): string => String(n).padStart(2, '0');54 const ago = (t: Date, sentence: boolean): string => {55 const now = Date.now();56 const s = (now - t.getTime()) / 1000;57 if (s > -300 && s < 60) return sentence ? 'just now' : 'now';58 let n = '';59 if (s > 0 && s < 3600) n = `${Math.floor(s / 60)}m`;60 else if (s > 0 && s < 86400) n = `${Math.floor(s / 3600)}h`;61 else if (s > 0 && s < 7 * 86400) n = `${Math.floor(s / 86400)}d`;62 else if (s > 0 && s < 28 * 86400) n = `${Math.floor(s / (7 * 86400))}w`;63 if (n) return sentence ? `${n} ago` : n;64 if (t.getFullYear() === new Date(now).getFullYear()) return day.format(t);65 return sentence ? dayYear.format(t) : `${t.getFullYear()}-${two(t.getMonth() + 1)}-${two(t.getDate())}`;66 };67 const times = (): void => {68 for (const el of document.querySelectorAll<HTMLTimeElement>('time[datetime]')) {69 const t = new Date(el.dateTime);70 if (isNaN(t.getTime())) continue;71 el.textContent = ago(t, el.hasAttribute('data-ago'));72 el.title = full.format(t);73 }74 };7576 // Repo tabs scroll sideways on small screens; keep the current one in view.77 const tabs = document.querySelector<HTMLElement>('nav.tabs');78 const cur = tabs?.querySelector<HTMLElement>('.cur');79 if (tabs && cur) {80 const over = cur.getBoundingClientRect().right - tabs.getBoundingClientRect().right;81 if (over > 0) tabs.scrollLeft += over + 24;82 }8384 // The owner pages' and /topics/' shelves (shelves in macros.njk). Without JS85 // the page shows "all", or the shelf its address names; here the tabs pick86 // the panel: "all" (every repo in one) or one shelf's. The globe hides87 // repos without a site, the sort is newest first or by name, and the88 // tabs count what they show. On an owner's page the activity grid counts89 // the picked tab's repos, and a day picked in it narrows the list to the90 // repos with commits that day. The choices outlive live.ts swapping in a91 // rebuilt page.92 const pick = { shelf: '', site: false, az: false, day: '' };93 const shelves = (): void => {94 const bar = document.querySelector<HTMLElement>('[data-shelves]');95 if (!bar) return;96 bar.closest('.shelves')?.classList.add('shelves-js');97 for (const b of bar.querySelectorAll<HTMLElement>('[hidden]')) b.hidden = false;98 const tabs = [...bar.querySelectorAll<HTMLElement>('a[data-shelf]')];99 for (const t of tabs) {100 const cur = t.dataset.shelf === pick.shelf;101 t.classList.toggle('cur', cur);102 if (cur) t.setAttribute('aria-current', 'page');103 else t.removeAttribute('aria-current');104 }105 bar.querySelector('[data-has-site]')?.setAttribute('aria-pressed', String(pick.site));106 for (const b of bar.querySelectorAll<HTMLElement>('[data-sort]')) b.setAttribute('aria-pressed', String((b.dataset.sort === 'name') === pick.az));107 const panels = [...document.querySelectorAll<HTMLElement>('section.shelf')];108 const on = panels.find((sec) => sec.dataset.shelf === pick.shelf) ?? panels[0];109 const grid = document.querySelector<HTMLElement>('.shelves [data-activity]');110 const data = grid ? ownerData() : null;111 const day = data && pick.day ? data.days[pick.day] ?? {} : null;112 let shown = 0;113 for (const sec of panels) {114 const ul = sec.querySelector('ul')!;115 const cards = [...ul.children] as HTMLElement[];116 cards.sort((a, b) => (pick.az ? a.dataset.name!.localeCompare(b.dataset.name!) : b.dataset.date!.localeCompare(a.dataset.date!)));117 ul.append(...cards);118 let n = 0;119 for (const c of cards) n += Number(!(c.hidden = pick.site && !c.hasAttribute('data-site')));120 const tab = tabs.find((t) => t.dataset.shelf === sec.dataset.shelf)?.querySelector('.n');121 if (tab) tab.textContent = String(n);122 // A picked day: this panel's repos with commits that day.123 let repos = 0, commits = 0;124 for (const c of cards) {125 const k = day && sec === on && !c.hidden ? day[c.dataset.repo ?? ''] ?? 0 : 0;126 const span = c.querySelector<HTMLElement>('.card-day');127 c.classList.toggle('on-day', !!day);128 if (span) {129 span.hidden = !k;130 span.textContent = k ? plural(k, 'commit') : '';131 }132 if (day && sec === on && !c.hidden) {133 c.hidden = !k;134 if (k) [repos, commits] = [repos + 1, commits + k];135 }136 }137 const head = sec.querySelector<HTMLElement>('.day-head');138 if (head) {139 head.hidden = !day;140 head.querySelector('.day-title')!.textContent = day ? words(pick.day) : '';141 head.querySelector('.day-note')!.textContent = day ? `${plural(repos, 'repo')}, ${plural(commits, 'commit')}` : '';142 }143 const none = sec.querySelector<HTMLElement>('.day-empty');144 if (none) none.hidden = !day || repos > 0;145 sec.hidden = sec !== on || (!n && !day);146 if (!sec.hidden) shown++;147 }148 const empty = document.querySelector<HTMLElement>('.shelves-empty');149 if (empty) empty.hidden = shown > 0;150 // The grid counts what the picked tab (and the globe) leave.151 if (grid && data) {152 const names = new Set([...(on?.querySelectorAll<HTMLElement>('.card') ?? [])].filter((c) => !(pick.site && !c.hasAttribute('data-site'))).map((c) => c.dataset.repo));153 const counts: Record<string, number> = {};154 for (const [d, per] of Object.entries(data.days)) for (const [r, k] of Object.entries(per)) if (names.has(r)) counts[d] = (counts[d] ?? 0) + k;155 paint(grid, counts);156 }157 };158 document.addEventListener('click', (e) => {159 const el = (e.target as Element | null)?.closest<HTMLElement>('[data-shelves] [data-shelf], [data-shelves] [data-has-site], [data-shelves] [data-sort]');160 if (!el) return;161 e.preventDefault();162 if (el.dataset.shelf !== undefined) pick.shelf = el.dataset.shelf;163 else if (el.hasAttribute('data-has-site')) pick.site = !pick.site;164 else if (el.dataset.sort) pick.az = el.dataset.sort === 'name';165 shelves();166 if (el.dataset.shelf !== undefined && document.querySelector('[data-activity]')) address();167 });168169 // The activity grid (ui.activity in ui.njk, lib/activity.ts): a day a170 // button, coloured by its count's share of the busiest day. A click (or171 // Enter or Space) picks a day or, picked, lets it go; "show all" lets it172 // go too; the arrow keys move a day (up, down) or a week (left, right).173 // The address keeps the day so a narrowed list can be shared: #<day>, or174 // #<shelf>/<day> with a shelf picked on an owner's page, beside the175 // shelves' own #<shelf>. On an owner's page the counts are commits by176 // repo (the page's JSON, then the fresher activity.json forge lake177 // writes beside it). A repo's insights has a still grid, a span a day178 // and nothing to pick: its counts are events, from its activity.json.179 const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];180 const words = (d: string): string => `${MONTHS[Number(d.slice(5, 7)) - 1]} ${Number(d.slice(8))}, ${d.slice(0, 4)}`;181 const plural = (n: number, w: string): string => `${n.toLocaleString('en-US')} ${w}${n === 1 ? '' : 's'}`;182 const level = (n: number, max: number): number => (n <= 0 || max <= 0 ? 0 : Math.min(4, Math.ceil((4 * n) / max)));183 // A repo's events carry their times, so their days are the reader's,184 // as their times are; the page without JS has UTC days.185 const localDay = (ms: number): string => {186 const t = new Date(ms);187 return `${t.getFullYear()}-${two(t.getMonth() + 1)}-${two(t.getDate())}`;188 };189 type Days = Record<string, Record<string, number>>;190 let fresh: { repos?: Record<string, number[]>; events?: number[] } | null = null;191 let asked = false;192 // An owner's commits by the reader's day, then repo: each repo's commit193 // times come as minutes, the first since 1970 and each after it since the194 // one before (lib/activity.ts).195 let decoded: { from: unknown; days: Days } | null = null;196 const ownerData = (): { days: Days } => {197 const from = fresh?.repos ? fresh : document.querySelector('.activity-data')?.textContent ?? '';198 if (decoded?.from === from) return decoded;199 let src: { repos?: Record<string, number[]> } | null = null;200 try {201 src = typeof from === 'string' ? JSON.parse(from) : from;202 } catch {}203 const days: Days = {};204 for (const [repo, enc] of Object.entries(src?.repos ?? {})) {205 let m = 0;206 enc.forEach((d, i) => {207 m = i ? m + d : d;208 const day = localDay(m * 60000);209 (days[day] ??= {})[repo] = (days[day][repo] ?? 0) + 1;210 });211 }212 decoded = { from, days };213 return decoded;214 };215216 // Paint a grid from counts by day (or its own, without); a still grid's217 // days have nothing to press.218 const paint = (g: HTMLElement, counts: Record<string, number> | null): void => {219 const cells = [...g.querySelectorAll<HTMLElement>('[data-day]')];220 const ns = cells.map((c) => (counts ? counts[c.dataset.day!] ?? 0 : Number(c.dataset.n)));221 const max = Math.max(0, ...ns);222 const noun = g.dataset.noun ?? 'commit';223 let total = 0;224 cells.forEach((c, i) => {225 total += ns[i];226 const what = `${c.dataset.day}: ${plural(ns[i], noun)}`;227 c.setAttribute('data-n', String(ns[i]));228 c.setAttribute('class', (c.getAttribute('class') ?? '').replace(/\bl\d\b/, `l${level(ns[i], max)}`));229 c.setAttribute('aria-label', what);230 c.title = what;231 if (c.hasAttribute('aria-pressed')) c.setAttribute('aria-pressed', String(c.dataset.day === pick.day));232 });233 const t = g.closest('.panel')?.querySelector('.act-total');234 if (t) t.textContent = plural(total, noun);235 };236 // A repo's insights: its events' times, counted on the reader's days.237 // Until they come (or with an export that has no times), the page's own.238 const insights = (): void => {239 const g = document.querySelector<HTMLElement>('.insights [data-activity]');240 if (!g) return;241 const events = fresh?.events;242 if (!Array.isArray(events) || !events.every(Number.isFinite)) return paint(g, null);243 const counts: Record<string, number> = {};244 for (const t of events) {245 const d = localDay(t);246 counts[d] = (counts[d] ?? 0) + 1;247 }248 paint(g, counts);249 };250 // The fresher export, once a page (and again after live.ts swaps one in).251 const refresh = (): void => {252 const g = document.querySelector<HTMLElement>('[data-activity][data-src]');253 if (asked || !g || typeof fetch !== 'function') return;254 asked = true;255 fetch(g.dataset.src!, { cache: 'no-cache' })256 .then((r) => (r.ok ? r.json() : null))257 .then((d) => {258 if (d && typeof d === 'object') fresh = d;259 activity();260 })261 .catch(() => activity());262 };263 const activity = (): void => {264 shelves();265 insights();266 // Elsewhere (the style guide's), a grid only shows the pick.267 for (const g of document.querySelectorAll<HTMLElement>('[data-activity]')) if (!g.closest('.shelves, .insights')) paint(g, null);268 };269 const address = (): void => {270 const h = [pick.shelf, pick.day].filter(Boolean).join('/');271 history.replaceState(null, '', h ? `#${h}` : location.pathname + location.search);272 };273 document.addEventListener('click', (e) => {274 const t = e.target as Element | null;275 const cell = t?.closest<HTMLElement>('[data-activity] button[data-day]');276 const reset = t?.closest<HTMLElement>('[data-day-reset]');277 if (!cell && !reset) return;278 e.preventDefault();279 pick.day = cell && pick.day !== cell.dataset.day ? cell.dataset.day! : '';280 activity();281 address();282 });283 document.addEventListener('keydown', (e) => {284 const cell = (e.target as Element | null)?.closest<HTMLElement>('[data-activity] button[data-day]');285 const step = ({ ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 } as Record<string, number>)[e.key];286 if (!cell || !step) return;287 e.preventDefault();288 const cells = [...cell.parentElement!.querySelectorAll<HTMLElement>('button[data-day]')].filter((c) => c.offsetParent !== null);289 const to = cells[cells.indexOf(cell) + step];290 if (!to) return;291 cell.tabIndex = -1;292 to.tabIndex = 0;293 to.focus();294 });295296 // The style guide's examples (design.njk): a toggle flips, a segment is297 // picked from its group.298 document.addEventListener('click', (e) => {299 const b = (e.target as Element | null)?.closest<HTMLElement>('[data-demo] button[aria-pressed]');300 if (!b) return;301 const group = b.closest('.segmented');302 if (group) for (const o of group.querySelectorAll('button')) o.setAttribute('aria-pressed', String(o === b));303 else b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));304 });305306 // A link to one shelf (/cat/#games, from the home page's topics) opens on307 // A link to one shelf (/cat/#games, from the home page's topics) opens on308 // it, and so does following one, or going back, on the page itself: the309 // address changes but the page doesn't load again. A day after it310 // (#games/2026-10-08), or alone, picks that day in the activity grid.311 // Any other address is "all".312 const atHash = (): void => {313 const at = decodeURIComponent(location.hash.slice(1));314 const dated = /^(?:(.+)\/)?(\d{4}-\d{2}-\d{2})$/.exec(at);315 const shelf = dated ? dated[1] ?? '' : at;316 pick.shelf = shelf && document.querySelector(`[data-shelves] a[data-shelf="${CSS.escape(shelf)}"]`) ? shelf : '';317 pick.day = dated && document.querySelector(`[data-activity] button[data-day="${dated[2]}"]`) ? dated[2] : '';318 };319 atHash();320 window.addEventListener('hashchange', () => {321 atHash();322 activity();323 });324325 const content = (): void => {326 wrap(wrapped());327 times();328 activity();329 refresh();330 };331 content();332 setInterval(times, 60000);333 // live.ts swaps in rebuilt content.334 document.addEventListener('forge:content', () => {335 [fresh, asked] = [null, false];336 content();337 });338339 // Clicking into a clone URL selects it, for copying by hand.340 for (const input of document.querySelectorAll<HTMLInputElement>('.copyrow input')) input.addEventListener('focus', () => input.select());341})();