1{# Shared markup. Import it with context, so the CSS blocks in these macros2 reach the page's bundle: {% import "macros.njk" as m with context %}3 The controls themselves are in ui.njk. #}4{% import "ui.njk" as ui %}56{# A page's description and its link preview in chat apps (OpenGraph).7 og:url needs the forge's address, FORGE_BASE_URL. #}8{% macro meta(title, description, href = '') %}9{% if description %}10<meta name="description" content="{{ description }}">11{% endif %}12<meta property="og:type" content="website">13<meta property="og:site_name" content="{{ forge.config.name }}">14<meta property="og:title" content="{{ title }}">15{% if description %}16<meta property="og:description" content="{{ description }}">17{% endif %}18{% if href and forge.config.baseUrl %}19<meta property="og:url" content="{{ forge.config.baseUrl }}{{ href }}">20{% endif %}21{% endmacro %}2223{# Review, combined diff and revision pages: the review's description, or what it is. #}24{% macro reviewMeta(rv, repo, title, href) %}25{% set about = rv.status ~ ' review of ' ~ rv.branch ~ ' into ' ~ rv.target ~ (' by ' ~ rv.author if rv.author else '') ~ ', ' ~ (rv.layer.length | plural('commit')) %}26{{ meta(title, (rv.opts.description | reviewMarkdown(repo, rv, true) | excerpt(true)) or about, href) }}{% endmacro %}2728{# Jev's label for a commit (lib/jev-commits.ts): its kind, its risk when29 moderate or high, and a note when its message may not describe its diff. #}30{# Jev on a commit (lib/jev-commits.ts): its kind, its risk as the score Jev31 gave (0 can't break anything, 3 likely to), and a mark when the message32 may not describe the diff. Estimates from the diff, not findings. #}33{% macro jevKind(c) %}{% if c.jev %}<span class="jev-kind {{ c.jev.kind }}" title="kind of change, Jev's estimate">{{ c.jev.kind }}</span>{% endif %}{% endmacro %}34{% macro jevRisk(c) %}{% if c.jev and c.jev.riskScore != null %}<span class="jev-score{% if c.jev.risk %} {{ c.jev.risk }}{% endif %}" title="risk score {{ c.jev.riskScore | tenths }} of 3: Jev's estimate from the diff of how likely this is to break something that worked (0 can't, 3 likely)">{{ c.jev.riskScore | tenths }}</span>{% endif %}{% endmacro %}35{% macro jevMismatch(c) %}{% if c.jev and c.jev.mismatch %} <span class="jev-mismatch" title="the message may not describe the diff, Jev's estimate">≠</span>{% endif %}{% endmacro %}36{% macro jevLabel(c) %}{% if c.jev %}{{ jevKind(c) }}{% if c.jev.riskScore != null %} <span class="desc">risk score</span> {{ jevRisk(c) }}<span class="desc"> of 3, Jev's estimate</span>{% endif %}{% if c.jev.mismatch %} <span class="jev-note">the message may not describe the diff</span>{% endif %}{% endif %}{% endmacro %}3738{# What a repo is, by Jev (lib/jev-commits.ts): s is a repo summary or a home page. #}39{% macro topic(s) %}{% if s.topic %} <span class="jev-topic" title="what this repo is, by Jev">{{ s.topic }}</span>{% endif %}{% endmacro %}4041{# Commit list: subject first; author, date, size and CI as the meta line on phones. #}42{% macro log(commits, repo) %}43{% set jev = commits | selectattr('jev') | length %}44<table class="list stack nav log">45<thead><tr>{% if jev %}<th class="kind">type</th>{% endif %}<th>commit</th><th>author</th><th class="date">date</th><th class="num">+</th><th class="num">-</th>{% if jev %}<th class="num" title="Jev's estimate of how likely a change is to break something, 0 to 3">risk</th>{% endif %}<th>ci</th></tr></thead>46<tbody>47{% for c in commits %}48<tr>{% if jev %}<td class="kind">{{ jevKind(c) }}</td>{% endif %}<td class="main"><a href="{{ repo.urls.commit(c.sha).href }}">{{ c.subject }}</a>{{ jevMismatch(c) }}</td><td class="nowrap">{{ c.author.name }}</td><td class="date">{{ c.author.date | date }}</td><td class="num i">+{{ c.stats.add }}</td><td class="num d">-{{ c.stats.del }}</td>{% if jev %}<td class="num risk">{{ jevRisk(c) }}</td>{% endif %}<td>{{ c.ci | ciHtml | safe }}</td></tr>49{% endfor %}50</tbody>51</table>52{% endmacro %}5354{# The branch list is a small shared file loaded when the picker opens, so a55 new branch doesn't change every page. Without JS it links to the branches page. #}56{% macro branches(repo, current, kind) %}57<details class="picker" fx-action="{{ repo.urls.branchMenu(kind).href }}" fx-trigger="toggle" fx-target="#branch-menu" fx-swap="innerHTML">58<summary>{{ ui.icon('branch') }}<b>{{ current }}</b></summary>59<div id="branch-menu"><a href="{{ repo.urls.refs.href }}">all branches</a></div>60</details>61{% endmacro %}6263{% macro crumbs(repo, branch, list) %}64<span class="crumbs"><a href="{{ repo.urls.tree(branch).href }}">{{ repo.name }}</a>{% for c in list %} / <a href="{{ c.href }}">{{ c.name }}</a>{% endfor %}</span>65{% endmacro %}6667{% macro status(s) %}{{ ui.status(s) }}{% endmacro %}6869{% macro reviewci(ci) %}70{% if ci %}<span class="ci {{ ci.state }}" title="{{ ci.pass }} of {{ ci.total }} commits passed">{{ '✓' if ci.state == 'pass' else ('✗' if ci.state == 'fail' else '–') }} {{ ci.pass }}/{{ ci.total }}</span>{% else %}<span class="desc">no CI</span>{% endif %}71{% endmacro %}7273{# Each person's approval; stale once the tree they approved has changed. #}74{% macro approvals(r) %}75{% for a in r.approvals %}<span class="appr{% if not a.current %} stale{% endif %}" title="{{ a.user }} approved{% if a.rev %} r{{ a.rev }}{% endif %}{% if not a.current %}, changed since{% endif %}">✔ {{ a.user }}{% if not a.current %}{% if a.rev %} r{{ a.rev }}{% endif %}, stale{% endif %}</span>{% endfor %}76{% endmacro %}7778{# One review in a list: its title on the first line with CI and approvals79 at the right, the rest on a quieter second line. depth: how far up a80 stack, shown nested under the layer below with a rule joining them. A81 revised review links what changed in its latest push. #}82{% macro reviewItem(r, repo, depth = 0) %}83{% css %}84.rv { position: relative; display: grid; grid-template-columns: auto 1fr auto; column-gap: var(--space-3); row-gap: var(--space-1); align-items: baseline; padding: var(--space-2) var(--space-3); }85.rv:hover, .rv:focus-within, .rv.sel { background: var(--hover); }86.rv.sel { box-shadow: inset 2px 0 0 var(--accent); }87.rv > .status { grid-row: 1; }88.rv-title { min-width: 0; font-weight: var(--fw-strong); overflow-wrap: anywhere; }89.rv-title a { color: var(--fg); }90.rv-title a:hover { color: var(--fg); }91.rv-title a::after { content: ''; position: absolute; inset: 0; }92.rv-side { display: flex; gap: var(--space-3); align-items: baseline; justify-content: flex-end; white-space: nowrap; font: var(--fw-regular) var(--text-xs) var(--mono); }93.rv-side .desc { font-family: var(--sans); font-size: var(--text-sm); }94.rv-meta { grid-column: 2 / 4; display: flex; flex-wrap: wrap; column-gap: var(--space-4); row-gap: var(--space-half); font: var(--fw-regular) var(--text-xs)/1.5 var(--mono); color: var(--muted); }95.rv-meta time, .rv-meta .mono { font-size: inherit; }96.rv-meta .num { font-variant-numeric: tabular-nums; }97.rv-meta .add { color: var(--add-fg); }98.rv-meta .del { color: var(--del-fg); }99.rv-meta a, .rv-side a { position: relative; z-index: 1; }100.rv.layer { padding-left: calc(var(--space-3) + var(--depth) * var(--space-6)); }101.rv.layer::before { content: ''; position: absolute; top: 0; bottom: 0; left: calc(var(--space-1) + var(--depth) * var(--space-6)); border-left: var(--hairline) solid var(--border-strong); }102.appr { white-space: nowrap; font-weight: var(--fw-strong); color: var(--pass); }103.appr + .appr { margin-left: var(--space-2); }104.appr.stale { color: var(--warn); font-weight: var(--fw-regular); }105@media (max-width: 720px) {106 .rv { grid-template-columns: auto 1fr; }107 .rv-side { grid-column: 2; justify-content: flex-start; }108 .rv-meta { grid-column: 1 / 3; }109 .rv.layer { padding-left: calc(var(--space-3) + var(--depth) * var(--space-3)); }110 .rv.layer::before { left: calc(var(--space-1) + var(--depth) * var(--space-3)); }111}112{% endcss %}113{% set last = r.revisions[r.revisions.length - 1] %}114<li class="rv{% if depth %} layer{% endif %}"{% if depth %} style="--depth: {{ depth }}"{% endif %}>115{{ status(r.status) }}116<span class="rv-title"><a href="{{ repo.urls.review(r.id).href }}">{{ r.title }}</a></span>117<span class="rv-side">{{ reviewci(r.ci) }}{% if r.approvals.length %}<span>{{ approvals(r) }}</span>{% endif %}</span>118<span class="rv-meta"><span class="num">#{{ r.id }}</span><span class="mono">{{ r.branch }} → {{ r.target }}</span>{% if r.author %}<span>{{ r.author }}</span>{% endif %}{% if r.commits.length %}<span>{{ r.commits.length | plural('commit') }}</span>{% endif %}{% if r.combinedStats and (r.combinedStats.add or r.combinedStats.del) %}<span class="num"><span class="add">+{{ r.combinedStats.add }}</span> <span class="del">−{{ r.combinedStats.del }}</span></span>{% endif %}<span class="date">{{ r.updated | date }}</span>{% if last and last.view %}<a href="{{ repo.urls.revision(r.id, last.n).href }}" title="what changed since r{{ last.prev }}">r{{ last.n }}</a>{% endif %}</span>119</li>120{% endmacro %}121122{# list: reviews nested by stack, as {review, depth} (nest() in lib/reviews.ts). #}123{% macro reviews(list, repo) %}124<ul class="rows rv-list nav-list">125{% for e in list %}{{ reviewItem(e.review, repo, e.depth) }}{% endfor %}126</ul>127{% endmacro %}128129{# A directory listing: folders first, then files. #}130{% macro files(entries, parentHref) %}131<table class="list files nav">132<tbody>133{% if parentHref %}<tr><td class="file-icon">{{ ui.icon('up') }}</td><td class="main"><a href="{{ parentHref }}" data-key="u">..</a></td><td></td></tr>{% endif %}134{% for e in entries %}135<tr><td class="file-icon">{{ ui.icon('folder' if e.type == 'tree' else ('link' if e.type == 'commit' else 'file')) }}</td><td class="main">{% if e.href %}<a href="{{ e.href }}">{{ e.name }}</a>{% else %}{{ e.name }} <span class="desc">@ {{ e.oid | short }}</span>{% endif %}</td><td class="num size">{{ e.size | size if e.type == 'blob' else '' }}</td></tr>136{% endfor %}137</tbody>138</table>139{% endmacro %}140141{# Owner pages and /topics/: repos as rows, one panel at a time. Tabs142 above name the shelves (a repo's kind), "all" first: "all" is every repo143 in one panel, newest first, so what changed lately is at the top; a144 shelf's tab shows its repos alone. The tabs name the panel, so it has no145 head strip. Without JS the page shows "all", or the shelf its address146 names (/cat/#games, :target); with it (ui.ts) the tabs pick the panel.147 Beside them, how to look at the list, which needs JS and shows with it:148 the globe keeps only repos with a site (the tabs' counts follow it), and149 the sort is newest first or by name. keys.ts walks the rows with j and k.150 activity (an owner's page): their activity grid between the tabs and151 the list, counting the picked tab's repos; a day picked in it narrows152 the list to the repos with commits that day, and only then does the153 list have a head strip: the day, how many, and "show all". #}154{% macro shelves(repos, owners = false, activity = null) %}155{% css %}156.shelf-bar { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); align-items: center; margin: 0 0 var(--space-4); border-bottom: var(--hairline) solid var(--border); }157.shelf-bar nav.tabs { flex: 0 1 auto; min-width: 0; }158.shelf-bar.plain { border-bottom: 0; }159.shelf-bar .grow { flex: 1; }160.shelf-tools { display: flex; gap: var(--space-2); align-items: center; padding: var(--space-1) 0; }161.shelf-tools[hidden] { display: none; }162.shelf { scroll-margin-top: var(--space-3); }163.shelf-bar + .activity-panel { margin-top: calc(-1 * var(--space-1)); }164/* A day picked in the activity grid: each repo's commits that day, in165 place of its sites, reviews and time. */166.card.on-day .card-foot > :not(.card-day) { display: none; }167.card-day { min-width: var(--col-time); text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }168/* Without JS: "all", unless the address names a shelf. ui.ts marks the169 page with shelves-js and picks the panel itself. */170.shelves:not(.shelves-js) > .shelf[data-shelf]:not([data-shelf=""]):not(:target) { display: none; }171.shelves:not(.shelves-js):has(> .shelf:target) > .shelf[data-shelf=""] { display: none; }172/* Repos as rows in their shelf's panel: name, what it is, then its sites,173 reviews and last push. The rows share the panel's columns (subgrid), so174 names, descriptions and times line up; the time ends each row, as wide175 as "Sep 24" (--col-time). */176.cards { display: grid; grid-template-columns: minmax(8em, 15em) minmax(0, 1fr) auto; column-gap: var(--space-4); }177.card { position: relative; grid-column: 1 / -1; display: grid; grid-template-columns: minmax(8em, 15em) minmax(0, 1fr) auto; grid-template-columns: subgrid; column-gap: var(--space-4); align-items: baseline; min-width: 0; padding: var(--space-2) var(--space-3); }178.card:hover, .card:focus-within, .card.sel { background: var(--hover); }179.card.sel { box-shadow: inset 2px 0 0 var(--accent); }180.card-top { min-width: 0; }181.card-name { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: var(--fw-strong); color: var(--fg); }182.card-name:hover { color: var(--fg); text-decoration: underline; }183.card-name::after { content: ''; position: absolute; inset: 0; }184.card-name .desc { font-weight: var(--fw-regular); }185.card-desc { grid-column: 2; margin: 0; color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; }186.card-foot { grid-column: 3; display: flex; align-items: baseline; justify-content: flex-end; gap: var(--space-4); font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); white-space: nowrap; }187.card-foot a { position: relative; z-index: 1; }188.card-foot .site { display: inline-flex; align-items: center; gap: var(--space-1); align-self: center; }189.card-foot .grow { display: none; }190.card-foot time { min-width: var(--col-time); font-size: inherit; text-align: right; font-variant-numeric: tabular-nums; }191@media (max-width: 720px) {192 .cards { grid-template-columns: minmax(0, 1fr); }193 .card { grid-template-columns: minmax(0, 1fr); grid-template-columns: subgrid; row-gap: var(--space-1); }194 .card-desc, .card-foot { grid-column: 1; }195 .card-foot { justify-content: flex-start; }196 .card-foot time { min-width: 0; text-align: left; }197}198199{% endcss %}200{% set list = repos | shelves %}201<div class="shelves">202<div class="shelf-bar{% if list.length < 2 %} plain{% endif %}" id="shelves" data-shelves>203{% if list.length > 1 %}204{% set tabs = [{href: '#shelves', text: 'all', n: repos.length, cur: true, attrs: 'data-shelf=""'}] %}205{% for sh in list %}{% set _ = tabs.push({href: '#' + sh.id, text: sh.title, n: sh.repos.length, attrs: 'data-shelf="' + sh.id + '"'}) %}{% endfor %}206{{ ui.tabs('shelves', tabs) }}207{% endif %}208<span class="grow"></span>209<span class="shelf-tools" hidden>210{{ ui.iconToggle('globe', 'only repos with a site', false, 'data-has-site') }}211{{ ui.segmented('sort', [{icon: 'clock', label: 'newest first', pressed: true, attrs: 'data-sort="date"'}, {icon: 'az', label: 'by name, a to z', attrs: 'data-sort="name"'}]) }}212</span>213</div>214{% if activity %}215{% set head %}<b class="act-total">{{ activity.grid.total | thousands }} {{ 'commit' if activity.grid.total == 1 else 'commits' }}</b><span>in the last year</span>{% endset %}216{% call ui.panel(head, 'activity-panel') %}<div class="panel-body">{{ ui.activity(activity.grid, 'commits a day', 'data-src="activity.json"') }}</div>217<script type="application/json" class="activity-data">{{ activity.data | json }}</script>{% endcall %}218{% endif %}219{% if list.length > 1 %}220{% call ui.panel('', 'shelf', 'data-shelf=""') %}{{ dayHead() if activity }}{{ cards(repos | sort(true, true, 'lastCommit'), owners, activity) }}{% endcall %}221{% endif %}222{% for sh in list %}223{# A lone shelf is all there is: it shows whatever the address says. #}224{% call ui.panel('', 'shelf', 'data-shelf="' + (sh.id if list.length > 1 else '') + '" id="' + sh.id + '"') %}{{ dayHead() if activity }}{{ cards(sh.repos, owners, activity) }}{% endcall %}225{% endfor %}226<div class="panel shelves-empty" hidden><p class="empty">No repos here have a site.</p></div>227</div>228{% endmacro %}229230{# The head strip a shelf's panel has while a day is picked in the activity231 grid (ui.ts fills it in), and what it says when no repo had commits. #}232{% macro dayHead() %}<header class="panel-head day-head" hidden><h2 class="day-title"></h2><span class="day-note"></span><span class="end"><a href="#shelves" data-day-reset>show all</a></span></header>233<p class="empty day-empty" hidden>No commits that day.</p>234{% endmacro %}235236{# A shelf's repos as rows: name, what it is, its sites, reviews and last237 push. activity: with room for a picked day's commits. #}238{% macro cards(repos, owners, activity = null) %}239<ul class="rows cards nav-cards">240{% for s in repos %}241<li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if activity %} data-repo="{{ s.name }}"{% endif %}{% if s.counts.sites %} data-site{% endif %}>242<div class="card-top"><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>243{% if s.description %}<p class="card-desc">{{ s.description }}</p>{% endif %}244<div class="card-foot">{{ sitesLink(s) }}{% if s.counts.reviews %}<a href="{{ s.urls.reviews.href }}">{{ s.counts.reviews | plural('review') }}</a>{% endif %}<span class="grow"></span>{{ s.lastCommit | date }}{% if activity %}<span class="card-day" hidden></span>{% endif %}</div>245</li>246{% endfor %}247</ul>248{% endmacro %}249250{% macro sitesLink(s) %}251{% if s.counts.sites == 1 and s.sites.length %}<a class="site" href="{{ s.sites[0].url }}" title="its site">{{ ui.icon('globe') }}{{ s.sites[0].name }}</a>252{% elif s.counts.sites %}<a class="site" href="{{ s.urls.sites.href }}" title="its sites">{{ ui.icon('globe') }}sites</a>{% endif %}253{% endmacro %}