@ok-ggwp/git-gloss 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ // Renders the gloss page from the embedded JSON. All text goes through
2
+ // textContent; nothing from the data is ever parsed as HTML.
3
+ (function () {
4
+ 'use strict'
5
+ var data = JSON.parse(document.getElementById('gloss-data').textContent)
6
+ var app = document.getElementById('app')
7
+ var hidden = new Set() // necessity values filtered out
8
+ var multi = data.commits.length > 1 || data.range !== null
9
+ // Group ids are unique within a commit, not across a range, so a range page
10
+ // keys cards by commit too: #g-<sha7>-<group>.
11
+ function key(c, id) { return multi ? c.sha.slice(0, 7) + '-' + id : id }
12
+
13
+ function el(tag, cls, text) {
14
+ var e = document.createElement(tag)
15
+ if (cls) e.className = cls
16
+ if (text !== undefined && text !== null) e.textContent = String(text)
17
+ return e
18
+ }
19
+ function add(parent) {
20
+ for (var i = 1; i < arguments.length; i++) if (arguments[i]) parent.appendChild(arguments[i])
21
+ return parent
22
+ }
23
+ function color(e, necessity) {
24
+ e.style.setProperty('--c', 'var(--n-' + necessity + ')')
25
+ return e
26
+ }
27
+ function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') }
28
+ function stats(added, removed) {
29
+ return add(el('span', 'stats'), el('span', 'plus', '+' + added), document.createTextNode(' '), el('span', 'minus', '−' + removed))
30
+ }
31
+ function groupLink(c, id) {
32
+ var a = el('a', null, id)
33
+ a.href = '#g-' + key(c, id)
34
+ a.addEventListener('click', function (ev) { ev.preventDefault(); focusGroup(key(c, id), true) })
35
+ return a
36
+ }
37
+
38
+ // ---- diff rendering ----
39
+ function hunkView(c, h) {
40
+ var head = add(el('div', 'hunk-head'),
41
+ el('span', 'range', h.ref),
42
+ el('span', 'file', h.oldFile ? h.oldFile + ' → ' + h.file : h.file),
43
+ el('span', 'range', h.range),
44
+ stats(h.added, h.removed))
45
+ if (h.status === 'changed') add(head, el('span', 'stale', 'changed since glossed'))
46
+ if (h.status === 'missing') {
47
+ add(head, el('span', 'stale', 'no longer in this commit'))
48
+ return add(el('div', 'hunk'), head)
49
+ }
50
+ if (h.alsoIn.length) {
51
+ var also = add(el('span', 'also'), document.createTextNode('shares lines with '))
52
+ h.alsoIn.forEach(function (id, i) { if (i) also.appendChild(document.createTextNode(', ')); also.appendChild(groupLink(c, id)) })
53
+ add(head, also)
54
+ }
55
+ var table = el('table', 'diff')
56
+ h.lines.forEach(function (l) {
57
+ var tr = el('tr', l.kind)
58
+ if (l.kind === 'meta' || l.kind === 'gap') {
59
+ var td = el('td', 'text', l.text)
60
+ td.colSpan = 4
61
+ add(tr, td)
62
+ } else {
63
+ add(tr,
64
+ el('td', 'ln', l.old === null ? '' : l.old),
65
+ el('td', 'ln', l.new === null ? '' : l.new),
66
+ el('td', 'mark', l.kind === 'add' ? '+' : l.kind === 'del' ? '−' : ' '),
67
+ el('td', 'text', l.text))
68
+ }
69
+ table.appendChild(tr)
70
+ })
71
+ return add(el('div', 'hunk'), head, add(el('div', 'scroll'), table))
72
+ }
73
+
74
+ // ---- group cards ----
75
+ var cards = []
76
+ function groupCard(c, g) {
77
+ var card = color(el('article', 'group'), g.necessity)
78
+ card.id = 'g-' + key(c, g.id)
79
+ card.tabIndex = -1
80
+ card.dataset.necessity = g.necessity
81
+
82
+ var title = add(el('div', 'group-title'),
83
+ el('span', 'badge', g.necessity),
84
+ el('span', 'intent', g.intent),
85
+ el('span', 'gid', g.id))
86
+ if (g.stale) add(title, el('span', 'stale', g.stale + ' of ' + plural(g.total, 'hunk') + ' changed or gone'))
87
+ add(title, stats(g.added, g.removed))
88
+
89
+ var facts = el('dl', 'facts')
90
+ function fact(label, value) {
91
+ if (!value) return
92
+ add(facts, el('dt', null, label), typeof value === 'string' ? el('dd', null, value) : add(el('dd'), value))
93
+ }
94
+ if (g.serves[0] !== 'task') {
95
+ var links = el('span')
96
+ g.serves.forEach(function (id, i) { if (i) links.appendChild(document.createTextNode(', ')); links.appendChild(groupLink(c, id)) })
97
+ fact('serves', links)
98
+ }
99
+ fact('if removed', g.removal_impact)
100
+ fact('evidence', g.evidence)
101
+ fact('notes', g.notes)
102
+
103
+ add(card, add(el('div', 'group-head'), title), facts)
104
+
105
+ if (g.hunks.length) {
106
+ var code = el('div', 'code')
107
+ code.hidden = true
108
+ var toggle = el('button', 'code-toggle')
109
+ toggle.type = 'button'
110
+ var rendered = false
111
+ function setOpen(open) {
112
+ if (open && !rendered) { g.hunks.forEach(function (h) { code.appendChild(hunkView(c, h)) }); rendered = true }
113
+ code.hidden = !open
114
+ toggle.textContent = (open ? '▾ Hide code' : '▸ Show code') + ' · ' + plural(g.hunks.length, 'hunk')
115
+ toggle.setAttribute('aria-expanded', String(open))
116
+ }
117
+ toggle.addEventListener('click', function () { setOpen(code.hidden) })
118
+ setOpen(false)
119
+ card.setOpen = setOpen
120
+ card.isOpen = function () { return !code.hidden }
121
+ add(card, toggle, code)
122
+ }
123
+ cards.push(card)
124
+ return card
125
+ }
126
+
127
+ // ---- page ----
128
+ function statement(parent, label, text) {
129
+ if (text) add(parent, add(el('p', 'statement'), el('span', 'label', label), document.createTextNode(text)))
130
+ }
131
+ function commitMeta(c) {
132
+ return add(el('div', 'commit'), el('span', 'sha', c.sha.slice(0, 10)),
133
+ el('span', null, c.author), el('span', null, new Date(c.date).toLocaleString()))
134
+ }
135
+
136
+ // One commit's groups, unannotated changes, risks and alternatives, with its
137
+ // entries in the sidebar.
138
+ function commitBody(c, main) {
139
+ var tocList = el('ol')
140
+ var groupsEl = el('section', 'groups')
141
+ c.groups.forEach(function (g) {
142
+ groupsEl.appendChild(groupCard(c, g))
143
+ var li = el('li')
144
+ li.dataset.necessity = g.necessity
145
+ li.dataset.key = key(c, g.id)
146
+ var a = add(el('a'), color(el('span', 'dot'), g.necessity), el('span', null, g.id))
147
+ a.href = '#g-' + key(c, g.id)
148
+ a.title = g.intent
149
+ a.addEventListener('click', function (ev) { ev.preventDefault(); focusGroup(key(c, g.id), false) })
150
+ tocList.appendChild(add(li, a))
151
+ })
152
+ add(main, groupsEl)
153
+
154
+ var h = multi ? 'h3' : 'h2'
155
+ if (c.unannotated.length) {
156
+ var unId = 'unannotated' + (multi ? '-' + c.sha.slice(0, 7) : '')
157
+ var un = add(el('section', 'section'), el(h, null, (c.glossed ? 'Unannotated changes' : 'Changes') + ' (' + c.unannotated.length + ')'))
158
+ var box = add(el('div', 'unannotated'), el('p', null, c.glossed
159
+ ? 'These changes are in the commit but no group explains them.'
160
+ : 'This commit has no gloss, so nothing explains these changes.'))
161
+ c.unannotated.forEach(function (hk) { box.appendChild(hunkView(c, hk)) })
162
+ un.id = unId
163
+ add(main, add(un, box))
164
+ var ua = add(el('a'), el('span', null, c.glossed ? 'unannotated' : 'unglossed diff'))
165
+ ua.href = '#' + unId
166
+ tocList.appendChild(add(el('li'), ua))
167
+ }
168
+ if (c.risks.length) {
169
+ var rl = el('ul', 'list')
170
+ c.risks.forEach(function (r) { rl.appendChild(el('li', null, r)) })
171
+ add(main, add(el('section', 'section'), el(h, null, 'Risks'), rl))
172
+ }
173
+ if (c.alternatives.length) {
174
+ var al = el('ul', 'list')
175
+ c.alternatives.forEach(function (x) {
176
+ al.appendChild(add(el('li'), el('strong', null, x.option), el('span', 'why', ' — ' + x.rejected_because)))
177
+ })
178
+ add(main, add(el('section', 'section'), el(h, null, 'Alternatives considered'), al))
179
+ }
180
+ return tocList
181
+ }
182
+
183
+ function render() {
184
+ var layout = el('div', 'layout')
185
+ var toc = el('nav', 'toc')
186
+ var main = el('main')
187
+ add(layout, toc, main)
188
+ var allGroups = []
189
+ data.commits.forEach(function (c) { allGroups = allGroups.concat(c.groups) })
190
+
191
+ // Whether each commit as it is now was reviewed, per stage; each stage's
192
+ // reports open on demand. Findings are shown as text.
193
+ function reviewsBlock(c, target) {
194
+ var box = el('div', 'reviews')
195
+ if (!c.reviews.length) { add(target, add(box, el('span', 'review-none', 'No reviews recorded'))); return }
196
+ c.reviews.forEach(function (r) {
197
+ var d = el('details', 'review')
198
+ add(d, add(el('summary'), el('span', r.state === 'seen' && r.verdict !== 'issues' ? (r.verdict === 'clean' ? 'review-ok' : 'review-none') : 'stale', r.phrase)))
199
+ r.entries.forEach(function (e) {
200
+ add(d, el('div', 'note', r.stage + ' by ' + e.agent + (e.model ? ':' + e.model : '') + (e.mode ? ' (' + e.mode + ')' : '') +
201
+ ', ' + e.at.slice(0, 16).replace('T', ' ') + ', of ' + e.target), el('pre', 'findings', e.findings))
202
+ })
203
+ add(box, d)
204
+ })
205
+ add(target, box)
206
+ }
207
+
208
+ // Header: the commit itself, or the range and its commits.
209
+ if (multi) {
210
+ var glossed = data.commits.filter(function (c) { return c.glossed }).length
211
+ var vetted = data.commits.filter(function (c) {
212
+ return c.glossed && c.reviews.some(function (r) { return r.stage === 'honesty' && r.state === 'seen' })
213
+ }).length
214
+ add(main, add(el('div', 'commit'), el('span', 'sha', data.range),
215
+ el('span', null, plural(data.commits.length, 'commit') + ', ' + glossed + ' glossed' + (glossed ? ', ' + vetted + ' honesty-reviewed' : ''))))
216
+ add(main, el('h1', null, 'Branch ' + data.range))
217
+ var list = el('ol', 'commit-list')
218
+ data.commits.forEach(function (c) {
219
+ var a = add(el('a'), el('span', 'sha', c.sha.slice(0, 7)), document.createTextNode(' ' + c.subject))
220
+ a.href = '#c-' + c.sha.slice(0, 7)
221
+ var li = add(el('li'), a)
222
+ if (!c.glossed) add(li, el('span', 'stale', 'no gloss'))
223
+ else if (c.warnings.length) add(li, el('span', 'stale', 'stale gloss'))
224
+ if (c.glossed) {
225
+ var honesty = c.reviews.filter(function (r) { return r.stage === 'honesty' })[0]
226
+ add(li, !honesty ? el('span', 'stale', 'not reviewed')
227
+ : honesty.state !== 'seen' ? el('span', 'stale', 'changed since review')
228
+ : honesty.verdict === 'issues' ? el('span', 'stale', 'honesty: issues found')
229
+ : el('span', 'review-ok', honesty.verdict === 'clean' ? 'honesty: no issues' : 'honesty-reviewed'))
230
+ }
231
+ list.appendChild(li)
232
+ })
233
+ data.merges.forEach(function (m) {
234
+ list.appendChild(add(el('li'), el('span', 'sha', m.sha.slice(0, 7)), document.createTextNode(' ' + m.subject),
235
+ el('span', 'stale', 'merge, not reviewed')))
236
+ })
237
+ add(main, list)
238
+ } else {
239
+ var only = data.commits[0]
240
+ add(main, commitMeta(only), el('h1', null, only.subject))
241
+ statement(main, 'Task', only.task)
242
+ statement(main, 'Summary', only.summary)
243
+ only.warnings.forEach(function (w) { add(main, el('div', 'warning', '⚠ ' + w)) })
244
+ reviewsBlock(only, main)
245
+ }
246
+
247
+ // Necessity overview across every commit: bar weighted by changed lines,
248
+ // chips filter groups.
249
+ var present = data.necessity.filter(function (n) { return allGroups.some(function (g) { return g.necessity === n.name }) })
250
+ var total = allGroups.reduce(function (s, g) { return s + g.weight }, 0) || 1
251
+ var bar = el('div', 'bar')
252
+ var chips = el('div', 'chips')
253
+ present.forEach(function (n) {
254
+ var groups = allGroups.filter(function (g) { return g.necessity === n.name })
255
+ var weight = groups.reduce(function (s, g) { return s + g.weight }, 0)
256
+ var seg = color(el('span'), n.name)
257
+ seg.style.width = (100 * weight / total) + '%'
258
+ seg.title = n.name + ': ' + Math.round(100 * weight / total) + '% of changed lines'
259
+ bar.appendChild(seg)
260
+
261
+ var chip = color(el('button', 'chip'), n.name)
262
+ chip.type = 'button'
263
+ chip.title = n.help + ' (click to show or hide)'
264
+ chip.setAttribute('aria-pressed', 'true')
265
+ add(chip, el('span', 'dot'), el('span', null, n.name), el('span', 'count', groups.length))
266
+ chip.dataset.necessity = n.name
267
+ chip.addEventListener('click', function () {
268
+ if (hidden.has(n.name)) hidden.delete(n.name); else hidden.add(n.name)
269
+ chip.setAttribute('aria-pressed', String(!hidden.has(n.name)))
270
+ applyFilter()
271
+ })
272
+ chips.appendChild(chip)
273
+ })
274
+ add(main, add(el('section', 'overview'), bar, chips,
275
+ el('div', 'note', 'Bar shows each category’s share of changed lines' + (multi ? ' across the range' : '') +
276
+ '. Click a category to hide or show its groups.')))
277
+
278
+ // Toolbar
279
+ var shown = el('span', 'shown')
280
+ var expand = el('button', 'btn', 'Expand all code')
281
+ var collapse = el('button', 'btn', 'Collapse all')
282
+ expand.type = collapse.type = 'button'
283
+ expand.addEventListener('click', function () { setAll(true) })
284
+ collapse.addEventListener('click', function () { setAll(false) })
285
+ add(main, add(el('div', 'toolbar'), shown, add(el('span'), expand, document.createTextNode(' '), collapse)))
286
+
287
+ // Commits, each with its groups; the sidebar groups them by commit.
288
+ data.commits.forEach(function (c) {
289
+ var target = main
290
+ if (multi) {
291
+ target = el('section', 'commit-section')
292
+ target.id = 'c-' + c.sha.slice(0, 7)
293
+ add(target, commitMeta(c), el('h2', null, c.subject))
294
+ statement(target, 'Task', c.task)
295
+ statement(target, 'Summary', c.summary)
296
+ c.warnings.forEach(function (w) { add(target, el('div', 'warning', '⚠ ' + w)) })
297
+ reviewsBlock(c, target)
298
+ add(main, target)
299
+ }
300
+ var tocList = commitBody(c, target)
301
+ var heading = multi ? c.sha.slice(0, 7) + ' ' + c.subject : 'Groups'
302
+ var tocHead = el('h2', multi ? 'commit-head' : null, heading)
303
+ if (multi) tocHead.title = c.subject
304
+ add(toc, tocHead, tocList)
305
+ })
306
+
307
+ var foot = el('footer')
308
+ add(foot, document.createTextNode('Keys: '), el('kbd', null, 'j'), document.createTextNode(' / '), el('kbd', null, 'k'),
309
+ document.createTextNode(' next / previous group · '), el('kbd', null, 'o'), document.createTextNode(' show or hide code · '),
310
+ el('kbd', null, 'e'), document.createTextNode(' expand all · '), el('kbd', null, 'c'),
311
+ document.createTextNode(' collapse all.'))
312
+ add(foot, el('p', null, 'A snapshot generated by git-gloss ' + new Date(data.generated).toLocaleString() +
313
+ '. It does not update if the gloss or commit changes; regenerate with git gloss html ' +
314
+ (data.range || data.commits[0].sha.slice(0, 10)) + '.'))
315
+ add(main, foot)
316
+
317
+ app.textContent = ''
318
+ app.appendChild(layout)
319
+
320
+ function applyFilter() {
321
+ var visible = 0
322
+ cards.forEach(function (c) {
323
+ var off = hidden.has(c.dataset.necessity)
324
+ c.classList.toggle('hidden', off)
325
+ if (!off) visible++
326
+ })
327
+ toc.querySelectorAll('li[data-necessity]').forEach(function (li) { li.classList.toggle('hidden', hidden.has(li.dataset.necessity)) })
328
+ shown.textContent = visible === cards.length
329
+ ? plural(cards.length, 'group')
330
+ : 'Showing ' + visible + ' of ' + plural(cards.length, 'group')
331
+ }
332
+ applyFilter()
333
+ showNecessity = function (n) {
334
+ hidden.delete(n)
335
+ chips.querySelectorAll('.chip').forEach(function (c) { if (c.dataset.necessity === n) c.setAttribute('aria-pressed', 'true') })
336
+ applyFilter()
337
+ }
338
+ }
339
+ var showNecessity = function () {}
340
+
341
+ function setAll(open) {
342
+ cards.forEach(function (c) { if (c.setOpen && !c.classList.contains('hidden')) c.setOpen(open) })
343
+ }
344
+
345
+ var current = -1
346
+ function focusGroup(k, openCode, instant) {
347
+ var i = cards.findIndex(function (c) { return c.id === 'g-' + k })
348
+ if (i < 0) return
349
+ var card = cards[i]
350
+ // Following a link to a filtered-out group turns its category back on.
351
+ if (card.classList.contains('hidden')) showNecessity(card.dataset.necessity)
352
+ cards.forEach(function (c) { c.classList.remove('focused') })
353
+ card.classList.add('focused')
354
+ current = i
355
+ if (openCode && card.setOpen) card.setOpen(true)
356
+ card.scrollIntoView({ block: 'start', behavior: instant ? 'auto' : 'smooth' })
357
+ document.querySelectorAll('.toc li').forEach(function (li) { li.classList.toggle('active', li.dataset.key === k) })
358
+ history.replaceState(null, '', '#g-' + k)
359
+ }
360
+ function step(dir) {
361
+ var visible = cards.filter(function (c) { return !c.classList.contains('hidden') })
362
+ if (!visible.length) return
363
+ var pos = visible.indexOf(cards[current])
364
+ var next = visible[Math.min(visible.length - 1, Math.max(0, pos + dir))] || visible[0]
365
+ focusGroup(next.id.slice(2), false)
366
+ }
367
+
368
+ document.addEventListener('keydown', function (ev) {
369
+ if (ev.metaKey || ev.ctrlKey || ev.altKey) return
370
+ var t = ev.target
371
+ if (t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)) return
372
+ if (ev.key === 'j') step(1)
373
+ else if (ev.key === 'k') step(-1)
374
+ else if (ev.key === 'o' && cards[current] && cards[current].setOpen) cards[current].setOpen(!cards[current].isOpen())
375
+ else if (ev.key === 'e') setAll(true)
376
+ else if (ev.key === 'c') setAll(false)
377
+ })
378
+
379
+ render()
380
+ if (location.hash.indexOf('#g-') === 0) {
381
+ try { focusGroup(decodeURIComponent(location.hash.slice(3)), true, true) } catch (e) { /* malformed hash */ }
382
+ }
383
+ })()
@@ -0,0 +1,147 @@
1
+ import { commitDiff, commitSubject, git } from '../git.js';
2
+ import { reviewStatus, statusPhrase, verdict } from '../reviews.js';
3
+ import { parseDiff } from '../diff.js';
4
+ import { loadGloss } from '../loader.js';
5
+ import { bodyLines, changedCount, gapText, isGap, refLabel, slice, sliceStats } from '../selection.js';
6
+ import { NECESSITY, NECESSITY_HELP, servesOf } from '../schema.js';
7
+ function pageReviews(sha) {
8
+ return reviewStatus(sha).map((s) => ({
9
+ stage: s.stage,
10
+ state: s.code === 'changed' ? 'code-changed'
11
+ : (s.stage === 'honesty' || s.stage === 'improve') && s.gloss === 'changed' ? 'gloss-changed' : 'seen',
12
+ verdict: verdict(s),
13
+ phrase: statusPhrase(s),
14
+ entries: s.reviews.map((r) => ({ agent: r.agent, model: r.model, mode: r.mode, at: r.at, target: r.target, findings: r.findings })),
15
+ }));
16
+ }
17
+ // The lines to show for a selection: the @@ header, then the slice, with
18
+ // changed lines that belong to other groups collapsed into gap rows.
19
+ export function pageLines(h, selected) {
20
+ const body = bodyLines(h);
21
+ if (body.every((l) => l.kind === 'meta'))
22
+ return body.map((l) => ({ kind: 'meta', old: null, new: null, text: l.raw }));
23
+ const header = { kind: 'meta', old: null, new: null, text: h.text.split('\n')[0] };
24
+ return [header, ...slice(body, selected).map((l) => isGap(l)
25
+ ? { kind: 'gap', old: null, new: null, text: gapText(l.gap) }
26
+ : { kind: l.kind, old: l.oldNo, new: l.newNo, text: l.kind === 'meta' ? l.raw : l.raw.slice(1) })];
27
+ }
28
+ // A glossed hunk that is no longer in the commit: shown as a placeholder so
29
+ // the reader can see what the gloss refers to, rather than it vanishing.
30
+ function missingHunk(h) {
31
+ const s = h.stored;
32
+ return {
33
+ id: h.id,
34
+ ref: refLabel(h.id, h.lines),
35
+ file: s?.file ?? '?',
36
+ oldFile: s?.oldFile && s.oldFile !== s.file ? s.oldFile : null,
37
+ range: s && (s.newLines || s.oldLines) ? `-${s.oldStart},${s.oldLines} +${s.newStart},${s.newLines}` : '',
38
+ status: 'missing',
39
+ added: 0,
40
+ removed: 0,
41
+ alsoIn: [],
42
+ lines: [],
43
+ };
44
+ }
45
+ function pageHunk(h, lines, status, alsoIn) {
46
+ const counts = sliceStats(h, lines);
47
+ return {
48
+ id: h.id,
49
+ ref: refLabel(h.id, lines),
50
+ file: h.file,
51
+ oldFile: h.oldFile && h.oldFile !== h.file ? h.oldFile : null,
52
+ range: h.newLines || h.oldLines ? `-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}` : 'file header',
53
+ status,
54
+ added: counts.added,
55
+ removed: counts.removed,
56
+ alsoIn,
57
+ lines: pageLines(h, lines),
58
+ };
59
+ }
60
+ const allLines = (h) => Array.from({ length: changedCount(h) }, (_, i) => i + 1);
61
+ export function commitData(gloss) {
62
+ const [author, date] = git(['log', '-1', '--format=%an%x00%aI', gloss.sha]).trim().split('\0');
63
+ const a = gloss.note.annotation;
64
+ // Which groups claim each changed line, so shared lines are split between
65
+ // their groups in the bar and each hunk can link to the groups it shares
66
+ // lines with. A header-only hunk counts as one shared unit.
67
+ const owners = new Map();
68
+ const claimed = (h, lines) => (changedCount(h) ? [...(lines ?? allLines(h))] : [0]);
69
+ for (const g of gloss.groups) {
70
+ for (const h of g.hunks) {
71
+ if (!h.current)
72
+ continue;
73
+ const byLine = owners.get(h.current) ?? new Map();
74
+ for (const i of claimed(h.current, h.lines))
75
+ byLine.set(i, [...(byLine.get(i) ?? []), g.group.id]);
76
+ owners.set(h.current, byLine);
77
+ }
78
+ }
79
+ const groups = gloss.groups
80
+ .map(({ group: g, hunks }) => {
81
+ const live = hunks.filter((h) => h.current !== null);
82
+ const counts = live.map((h) => sliceStats(h.current, h.lines));
83
+ const shares = (h) => claimed(h.current, h.lines).map((i) => owners.get(h.current).get(i));
84
+ return {
85
+ id: g.id,
86
+ intent: g.intent,
87
+ necessity: g.necessity,
88
+ serves: servesOf(g),
89
+ removal_impact: g.removal_impact,
90
+ evidence: g.evidence,
91
+ notes: g.notes,
92
+ stale: hunks.filter((h) => h.status !== 'ok').length,
93
+ total: hunks.length,
94
+ added: counts.reduce((n, c) => n + c.added, 0),
95
+ removed: counts.reduce((n, c) => n + c.removed, 0),
96
+ weight: live.reduce((n, h) => n + (changedCount(h.current) ? shares(h).reduce((w, o) => w + 1 / o.length, 0) : 0), 0),
97
+ hunks: hunks.map((h) => h.current
98
+ ? pageHunk(h.current, h.lines, h.status, [...new Set(shares(h).flat())].filter((id) => id !== g.id))
99
+ : missingHunk(h)),
100
+ };
101
+ })
102
+ .sort((x, y) => NECESSITY.indexOf(x.necessity) - NECESSITY.indexOf(y.necessity));
103
+ return {
104
+ sha: gloss.sha,
105
+ subject: gloss.subject,
106
+ author,
107
+ date,
108
+ glossed: true,
109
+ task: a.task,
110
+ summary: a.summary,
111
+ warnings: gloss.warnings,
112
+ groups,
113
+ unannotated: gloss.unannotated.map((u) => pageHunk(u.hunk, u.lines, 'unannotated', [])),
114
+ alternatives: a.alternatives ?? [],
115
+ risks: a.risks ?? [],
116
+ reviews: pageReviews(gloss.sha),
117
+ };
118
+ }
119
+ function unglossedData(sha) {
120
+ const [author, date] = git(['log', '-1', '--format=%an%x00%aI', sha]).trim().split('\0');
121
+ return {
122
+ sha,
123
+ subject: commitSubject(sha),
124
+ author,
125
+ date,
126
+ glossed: false,
127
+ task: '',
128
+ warnings: [],
129
+ groups: [],
130
+ unannotated: parseDiff(commitDiff(sha)).map((h) => pageHunk(h, null, 'unannotated', [])),
131
+ alternatives: [],
132
+ risks: [],
133
+ reviews: pageReviews(sha),
134
+ };
135
+ }
136
+ export function buildPageData(shas, range = null, merges = []) {
137
+ return {
138
+ range,
139
+ merges: merges.map((sha) => ({ sha, subject: commitSubject(sha) })),
140
+ necessity: NECESSITY.map((name) => ({ name, help: NECESSITY_HELP[name] })),
141
+ commits: shas.map((sha) => {
142
+ const gloss = loadGloss(sha);
143
+ return gloss ? commitData(gloss) : unglossedData(sha);
144
+ }),
145
+ generated: new Date().toISOString(),
146
+ };
147
+ }
@@ -0,0 +1,35 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
4
+ // JSON inside a <script> element: escape "<" so no string in the data, such as
5
+ // "</script>", can end the element early.
6
+ export function embedJson(data) {
7
+ return JSON.stringify(data).replace(/</g, '\\u003c');
8
+ }
9
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
10
+ export function renderPage(data) {
11
+ // The page only runs our own inline script. If escaping ever failed, an
12
+ // injected script would lack the nonce and the CSP would block it.
13
+ const nonce = randomBytes(16).toString('base64');
14
+ const csp = `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'`;
15
+ return `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <meta http-equiv="Content-Security-Policy" content="${csp}">
21
+ <title>${data.range ? `gloss ${escapeHtml(data.range)} (${data.commits.length} commit${data.commits.length === 1 ? '' : 's'})` : `gloss ${escapeHtml(data.commits[0].sha.slice(0, 7))}: ${escapeHtml(data.commits[0].subject)}`}</title>
22
+ <style nonce="${nonce}">
23
+ ${asset('./style.css')}
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div id="app"><noscript>This page needs JavaScript to render the gloss.</noscript></div>
28
+ <script type="application/json" id="gloss-data">${embedJson(data)}</script>
29
+ <script nonce="${nonce}">
30
+ ${asset('./client.js')}
31
+ </script>
32
+ </body>
33
+ </html>
34
+ `;
35
+ }