@iceinvein/agent-skills 0.1.30 → 0.1.32

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,113 @@
1
+ import { createHighlighter, type Highlighter } from 'shiki'
2
+
3
+ const LANG_BY_EXT: Record<string, string> = {
4
+ ts: 'typescript',
5
+ mts: 'typescript',
6
+ cts: 'typescript',
7
+ tsx: 'tsx',
8
+ js: 'javascript',
9
+ mjs: 'javascript',
10
+ cjs: 'javascript',
11
+ jsx: 'jsx',
12
+ py: 'python',
13
+ go: 'go',
14
+ rs: 'rust',
15
+ java: 'java',
16
+ rb: 'ruby',
17
+ sh: 'bash',
18
+ bash: 'bash',
19
+ zsh: 'bash',
20
+ json: 'json',
21
+ jsonc: 'json',
22
+ yml: 'yaml',
23
+ yaml: 'yaml',
24
+ md: 'markdown',
25
+ markdown: 'markdown',
26
+ html: 'html',
27
+ htm: 'html',
28
+ css: 'css',
29
+ sql: 'sql',
30
+ }
31
+
32
+ const REGISTERED_LANGS: string[] = [
33
+ 'typescript',
34
+ 'tsx',
35
+ 'javascript',
36
+ 'jsx',
37
+ 'python',
38
+ 'go',
39
+ 'rust',
40
+ 'java',
41
+ 'ruby',
42
+ 'bash',
43
+ 'json',
44
+ 'yaml',
45
+ 'markdown',
46
+ 'html',
47
+ 'css',
48
+ 'sql',
49
+ ]
50
+
51
+ const THEMES = { light: 'vitesse-light', dark: 'vitesse-dark' } as const
52
+
53
+ export function languageFromPath(path: string): string {
54
+ if (!path) return 'plaintext'
55
+ const dot = path.lastIndexOf('.')
56
+ if (dot < 0 || dot === path.length - 1) return 'plaintext'
57
+ const ext = path.slice(dot + 1).toLowerCase()
58
+ return LANG_BY_EXT[ext] ?? 'plaintext'
59
+ }
60
+
61
+ let cached: Promise<Highlighter> | null = null
62
+
63
+ export function getHighlighter(): Promise<Highlighter> {
64
+ if (!cached) {
65
+ cached = createHighlighter({
66
+ themes: [THEMES.light, THEMES.dark],
67
+ langs: REGISTERED_LANGS,
68
+ })
69
+ }
70
+ return cached
71
+ }
72
+
73
+ export function _resetHighlighterForTests(): void {
74
+ cached = null
75
+ }
76
+
77
+ export function highlightCodeBlock(highlighter: Highlighter, code: string, lang: string): string {
78
+ const safeLang = REGISTERED_LANGS.includes(lang) ? lang : 'plaintext'
79
+ return highlighter.codeToHtml(code, {
80
+ lang: safeLang,
81
+ themes: THEMES,
82
+ defaultColor: 'light',
83
+ })
84
+ }
85
+
86
+ export function highlightDiffSide(highlighter: Highlighter, code: string, lang: string): string[] {
87
+ const safeLang = REGISTERED_LANGS.includes(lang) ? lang : 'plaintext'
88
+ const html = highlighter.codeToHtml(code, {
89
+ lang: safeLang,
90
+ themes: THEMES,
91
+ defaultColor: 'light',
92
+ })
93
+ return extractShikiLines(html)
94
+ }
95
+
96
+ // Shiki wraps each line in <span class="line">...</span>. Each such outer span
97
+ // sits on one text line in the rendered HTML, but the first and last lines also
98
+ // carry the <pre>/<code> wrappers. We use a per-text-line greedy match (no `s`
99
+ // flag, so `.` stops at newlines) which correctly captures all inner spans while
100
+ // treating the last </span> on that text line as the outer closer.
101
+ // Empty input produces a single empty-span line which we normalise to [].
102
+ export function extractShikiLines(shikiHtml: string): string[] {
103
+ const results: string[] = []
104
+ for (const textLine of shikiHtml.split('\n')) {
105
+ const match = textLine.match(/<span class="line"[^>]*>(.*)<\/span>/)
106
+ if (match) {
107
+ results.push(match[1] ?? '')
108
+ }
109
+ }
110
+ // Shiki always emits at least one line span even for empty input; collapse that.
111
+ if (results.length === 1 && results[0] === '') return []
112
+ return results
113
+ }
@@ -116,10 +116,57 @@ function buildRiskParts(f: ReviewFinding): string[] {
116
116
  ]
117
117
  }
118
118
 
119
- function buildSuggestionBlock(f: ReviewFinding): string | null {
120
- const body = f.suggestion?.body.trim()
121
- if (!body) return null
122
- return ['```suggestion', body, '```'].join('\n')
119
+ function maxBacktickRun(s: string): number {
120
+ let max = 0
121
+ for (const m of s.matchAll(/`+/g)) {
122
+ if (m[0].length > max) max = m[0].length
123
+ }
124
+ return max
125
+ }
126
+
127
+ /**
128
+ * Pull a single fenced code block out of a suggestion body, returning its code
129
+ * plus any surrounding prose. LLMs sometimes wrap the real replacement in
130
+ * ```lang ... ``` with explanatory text around it; if that nested fence leaks
131
+ * into the outer ```suggestion``` wrapper it breaks GitHub's renderer and
132
+ * causes downstream markdown (e.g. the magpie:finding HTML marker) to spill
133
+ * into a stray code block. We hoist the inner code out so the suggestion body
134
+ * contains only what should be committed.
135
+ */
136
+ function splitEmbeddedFence(
137
+ body: string,
138
+ ): { code: string; preamble: string; postamble: string } | null {
139
+ const re = /(?:^|\n)(`{3,})[^\n`]*\n([\s\S]*?)\n\1(?=\n|$)/
140
+ const match = body.match(re)
141
+ if (match?.index === undefined) return null
142
+ const code = match[2] ?? ''
143
+ const matchStart = match.index + (body[match.index] === '\n' ? 1 : 0)
144
+ const matchEnd = matchStart + match[0].length - (body[match.index] === '\n' ? 1 : 0)
145
+ return {
146
+ code,
147
+ preamble: body.slice(0, matchStart).trim(),
148
+ postamble: body.slice(matchEnd).trim(),
149
+ }
150
+ }
151
+
152
+ type SuggestionParts = {
153
+ block: string | null
154
+ preamble: string | null
155
+ postamble: string | null
156
+ }
157
+
158
+ function buildSuggestion(f: ReviewFinding): SuggestionParts {
159
+ const raw = f.suggestion?.body.trim()
160
+ if (!raw) return { block: null, preamble: null, postamble: null }
161
+ const split = splitEmbeddedFence(raw)
162
+ const code = split?.code ?? raw
163
+ const fenceLen = Math.max(3, maxBacktickRun(code) + 1)
164
+ const fence = '`'.repeat(fenceLen)
165
+ return {
166
+ block: `${fence}suggestion\n${code}\n${fence}`,
167
+ preamble: split?.preamble || null,
168
+ postamble: split?.postamble || null,
169
+ }
123
170
  }
124
171
 
125
172
  function buildFindingMarker(f: ReviewFinding): string {
@@ -150,7 +197,7 @@ export function formatInlineBody(f: ReviewFinding): string {
150
197
  const label = SEVERITY_LABEL[f.severity]
151
198
  const focus = formatFocus(f)
152
199
  const metaLine = buildMetaLine([...buildRiskParts(f), focus ? `Focus · ${focus}` : null])
153
- const suggestion = buildSuggestionBlock(f)
200
+ const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
154
201
  const sections = formatFindingDescriptionMarkdown(f.description)
155
202
 
156
203
  return joinBlock([
@@ -159,8 +206,12 @@ export function formatInlineBody(f: ReviewFinding): string {
159
206
  metaLine,
160
207
  '',
161
208
  sections,
209
+ sugPre ? '' : null,
210
+ sugPre,
162
211
  suggestion ? '' : null,
163
212
  suggestion,
213
+ sugPost ? '' : null,
214
+ sugPost,
164
215
  '',
165
216
  buildFindingMarker(f),
166
217
  ])
@@ -181,7 +232,7 @@ export function formatConversationBody(f: ReviewFinding): string {
181
232
  ...buildRiskParts(f),
182
233
  ])
183
234
  const sections = formatFindingDescriptionMarkdown(f.description)
184
- const suggestion = buildSuggestionBlock(f)
235
+ const { block: suggestion, preamble: sugPre, postamble: sugPost } = buildSuggestion(f)
185
236
 
186
237
  return joinBlock([
187
238
  `### ${icon} ${label}: ${f.title}`,
@@ -189,8 +240,12 @@ export function formatConversationBody(f: ReviewFinding): string {
189
240
  metaLine,
190
241
  '',
191
242
  sections,
243
+ sugPre ? '' : null,
244
+ sugPre,
192
245
  suggestion ? '' : null,
193
246
  suggestion,
247
+ sugPost ? '' : null,
248
+ sugPost,
194
249
  '',
195
250
  buildFindingMarker(f),
196
251
  ])
@@ -1,4 +1,6 @@
1
+ import type { Highlighter } from 'shiki'
1
2
  import { parseFindingDescription } from './finding-description.ts'
3
+ import { highlightCodeBlock, languageFromPath } from './highlight.ts'
2
4
  import { isSuggestion, type ReviewFinding } from './types.ts'
3
5
 
4
6
  const DOMAIN_LABELS: Record<string, string> = {
@@ -35,24 +37,44 @@ export type RenderAnnotationOptions = {
35
37
  asCard: boolean
36
38
  /** When posting previously failed, surface the failure on the card. */
37
39
  failed?: { message: string }
40
+ highlighter: Highlighter
41
+ }
42
+
43
+ function renderInline(body: string): string {
44
+ // Backtick-delimited spans become inline <code> elements; everything else
45
+ // remains escaped text. Splitting on `([^`]+)` puts plain text at even
46
+ // indices and backtick contents at odd indices.
47
+ const parts = body.split(/`([^`]+)`/g)
48
+ return parts
49
+ .map((part, i) => {
50
+ const escaped = esc(part)
51
+ return i % 2 === 1 ? `<code class="inline-code">${escaped}</code>` : escaped
52
+ })
53
+ .join('')
38
54
  }
39
55
 
40
56
  function renderSections(description: string): string {
41
57
  const sections = parseFindingDescription(description)
42
58
  if (sections.length === 0) {
43
- return `<section class="section"><div class="section-label">Observation</div><div class="section-body">${esc(description.trim())}</div></section>`
59
+ return `<section class="section"><div class="section-label">Observation</div><div class="section-body">${renderInline(description.trim())}</div></section>`
44
60
  }
45
61
  return sections
46
62
  .map(
47
63
  (s) =>
48
- `<section class="section"><div class="section-label">${esc(s.label)}</div><div class="section-body">${esc(s.body)}</div></section>`,
64
+ `<section class="section"><div class="section-label">${esc(s.label)}</div><div class="section-body">${renderInline(s.body)}</div></section>`,
49
65
  )
50
66
  .join('')
51
67
  }
52
68
 
53
- function renderSuggestion(f: ReviewFinding): string {
69
+ function renderSuggestion(f: ReviewFinding, highlighter: Highlighter): string {
54
70
  if (!f.suggestion) return ''
55
- return `<section class="section"><div class="section-label">Suggested change</div><pre class="suggestion">${esc(f.suggestion.body)}</pre></section>`
71
+ const lang = languageFromPath(f.file)
72
+ const shiki = highlightCodeBlock(highlighter, f.suggestion.body, lang)
73
+ // Shiki emits `<pre class="shiki ..."><code>...</code></pre>`. Layer our
74
+ // existing `.suggestion` class on top so the border/padding/font-size
75
+ // styling continues to apply.
76
+ const withClass = shiki.replace('class="shiki', 'class="suggestion shiki')
77
+ return `<section class="section"><div class="section-label">Suggested change</div>${withClass}</section>`
56
78
  }
57
79
 
58
80
  function renderRisk(f: ReviewFinding): string {
@@ -87,7 +109,7 @@ export function renderAnnotation(f: ReviewFinding, opts: RenderAnnotationOptions
87
109
  ${statusChip}
88
110
  </div>
89
111
  ${renderSections(f.description)}
90
- ${renderSuggestion(f)}
112
+ ${renderSuggestion(f, opts.highlighter)}
91
113
  ${renderRisk(f)}
92
114
  </div>
93
115
  <button type="button" class="send-btn" data-action="post-one" data-finding-id="${esc(f.id)}" title="Post this finding" aria-label="post">▸</button>
@@ -1,4 +1,6 @@
1
+ import type { Highlighter } from 'shiki'
1
2
  import { buildPairedLines, type DiffHunk, type DiffLine, type PairedLine } from './diff-utils.ts'
3
+ import { highlightDiffSide, languageFromPath } from './highlight.ts'
2
4
  import { renderAnnotation } from './render-annotation.ts'
3
5
  import type { PostStatusEntry, PostStatusMap, ReviewFinding } from './types.ts'
4
6
 
@@ -20,6 +22,9 @@ export type RenderDiffInput = {
20
22
  findings: ReviewFinding[]
21
23
  postStatus: PostStatusMap
22
24
  selectedIds: Set<string>
25
+ highlighter: Highlighter
26
+ /** File path used to detect language for highlighting. */
27
+ file: string
23
28
  }
24
29
 
25
30
  function isPosted(status: PostStatusEntry | undefined): boolean {
@@ -33,11 +38,11 @@ function failedFrom(status: PostStatusEntry | undefined): { message: string } |
33
38
  return undefined
34
39
  }
35
40
 
36
- function lineRow(line: DiffLine): string {
41
+ function coloredLineRow(line: DiffLine, coloredContent: string): string {
37
42
  const cls = line.type === 'added' ? 'added' : line.type === 'removed' ? 'removed' : 'context'
38
43
  const lineNo = line.type === 'removed' ? line.oldLineNo : line.newLineNo
39
44
  const sign = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
40
- return `<div class="diff-row ${cls}"><span class="gutter">${lineNo ?? ''}</span><span class="sign">${sign}</span><span class="content">${esc(line.content)}</span></div>`
45
+ return `<div class="diff-row ${cls}"><span class="gutter">${lineNo ?? ''}</span><span class="sign">${sign}</span><span class="content">${coloredContent}</span></div>`
41
46
  }
42
47
 
43
48
  function findingsByLine(findings: ReviewFinding[]): Map<number, ReviewFinding[]> {
@@ -52,14 +57,43 @@ function findingsByLine(findings: ReviewFinding[]): Map<number, ReviewFinding[]>
52
57
  }
53
58
 
54
59
  export function renderUnifiedDiff(input: RenderDiffInput): string {
55
- const { hunks, findings, postStatus, selectedIds } = input
60
+ const { hunks, findings, postStatus, selectedIds, highlighter, file } = input
56
61
  if (hunks.length === 0) return `<div class="diff-empty">No changes</div>`
57
62
  const byLine = findingsByLine(findings)
63
+ const lang = languageFromPath(file)
58
64
  const parts: string[] = []
59
65
  hunks.forEach((h, hi) => {
60
66
  if (hi > 0) parts.push(`<div class="diff-hunk-sep">⋯</div>`)
67
+
68
+ const oldLines: string[] = []
69
+ const newLines: string[] = []
70
+ const rowSide: Array<{ side: 'old' | 'new'; idx: number }> = []
61
71
  for (const line of h.lines) {
62
- parts.push(lineRow(line))
72
+ if (line.type === 'removed') {
73
+ rowSide.push({ side: 'old', idx: oldLines.length })
74
+ oldLines.push(line.content)
75
+ } else if (line.type === 'added') {
76
+ rowSide.push({ side: 'new', idx: newLines.length })
77
+ newLines.push(line.content)
78
+ } else {
79
+ rowSide.push({ side: 'new', idx: newLines.length })
80
+ oldLines.push(line.content)
81
+ newLines.push(line.content)
82
+ }
83
+ }
84
+ const oldHighlighted = oldLines.length
85
+ ? highlightDiffSide(highlighter, oldLines.join('\n'), lang)
86
+ : []
87
+ const newHighlighted = newLines.length
88
+ ? highlightDiffSide(highlighter, newLines.join('\n'), lang)
89
+ : []
90
+
91
+ h.lines.forEach((line, i) => {
92
+ const ref = rowSide[i]
93
+ const colored =
94
+ (ref?.side === 'old' ? oldHighlighted[ref.idx] : newHighlighted[ref?.idx ?? 0]) ??
95
+ esc(line.content)
96
+ parts.push(coloredLineRow(line, colored))
63
97
  const targetLine = line.newLineNo ?? null
64
98
  if (targetLine != null) {
65
99
  const fs = byLine.get(targetLine)
@@ -67,6 +101,7 @@ export function renderUnifiedDiff(input: RenderDiffInput): string {
67
101
  for (const f of fs) {
68
102
  parts.push(
69
103
  renderAnnotation(f, {
104
+ highlighter,
70
105
  checked: selectedIds.has(f.id),
71
106
  posted: isPosted(postStatus[f.id]),
72
107
  failed: failedFrom(postStatus[f.id]),
@@ -76,33 +111,44 @@ export function renderUnifiedDiff(input: RenderDiffInput): string {
76
111
  }
77
112
  }
78
113
  }
79
- }
114
+ })
80
115
  })
81
116
  return parts.join('\n')
82
117
  }
83
118
 
84
- function pairedRow(pair: PairedLine): string {
85
- const cellL =
86
- pair.left == null
87
- ? `<div class="diff-cell empty"></div>`
88
- : `<div class="diff-cell ${pair.left.type}"><span class="gutter">${pair.left.oldLineNo ?? pair.left.newLineNo ?? ''}</span><span class="sign">${pair.left.type === 'removed' ? '-' : ' '}</span><span class="content">${esc(pair.left.content)}</span></div>`
89
- const cellR =
90
- pair.right == null
91
- ? `<div class="diff-cell empty"></div>`
92
- : `<div class="diff-cell ${pair.right.type}"><span class="gutter">${pair.right.newLineNo ?? pair.right.oldLineNo ?? ''}</span><span class="sign">${pair.right.type === 'added' ? '+' : ' '}</span><span class="content">${esc(pair.right.content)}</span></div>`
93
- return `<div class="diff-row split">${cellL}${cellR}</div>`
94
- }
95
-
96
119
  export function renderSplitDiff(input: RenderDiffInput): string {
97
- const { hunks, findings, postStatus, selectedIds } = input
120
+ const { hunks, findings, postStatus, selectedIds, highlighter, file } = input
98
121
  if (hunks.length === 0) return `<div class="diff-empty">No changes</div>`
99
122
  const byLine = findingsByLine(findings)
123
+ const lang = languageFromPath(file)
100
124
  const parts: string[] = []
101
125
  hunks.forEach((h, hi) => {
102
126
  if (hi > 0) parts.push(`<div class="diff-hunk-sep">⋯</div>`)
127
+
128
+ const leftLines: string[] = []
129
+ const rightLines: string[] = []
130
+ for (const line of h.lines) {
131
+ if (line.type === 'removed') leftLines.push(line.content)
132
+ else if (line.type === 'added') rightLines.push(line.content)
133
+ else {
134
+ leftLines.push(line.content)
135
+ rightLines.push(line.content)
136
+ }
137
+ }
138
+ const leftHighlighted = leftLines.length
139
+ ? highlightDiffSide(highlighter, leftLines.join('\n'), lang)
140
+ : []
141
+ const rightHighlighted = rightLines.length
142
+ ? highlightDiffSide(highlighter, rightLines.join('\n'), lang)
143
+ : []
144
+
103
145
  const pairs = buildPairedLines([h])
146
+ let li = 0
147
+ let ri = 0
104
148
  for (const pair of pairs) {
105
- parts.push(pairedRow(pair))
149
+ const leftColored = pair.left ? (leftHighlighted[li++] ?? esc(pair.left.content)) : null
150
+ const rightColored = pair.right ? (rightHighlighted[ri++] ?? esc(pair.right.content)) : null
151
+ parts.push(coloredPairedRow(pair, leftColored, rightColored))
106
152
  const target = pair.right?.newLineNo ?? null
107
153
  if (target != null) {
108
154
  const fs = byLine.get(target)
@@ -110,6 +156,7 @@ export function renderSplitDiff(input: RenderDiffInput): string {
110
156
  for (const f of fs) {
111
157
  parts.push(
112
158
  renderAnnotation(f, {
159
+ highlighter,
113
160
  checked: selectedIds.has(f.id),
114
161
  posted: isPosted(postStatus[f.id]),
115
162
  failed: failedFrom(postStatus[f.id]),
@@ -123,3 +170,19 @@ export function renderSplitDiff(input: RenderDiffInput): string {
123
170
  })
124
171
  return parts.join('\n')
125
172
  }
173
+
174
+ function coloredPairedRow(
175
+ pair: PairedLine,
176
+ leftColored: string | null,
177
+ rightColored: string | null,
178
+ ): string {
179
+ const cellL =
180
+ pair.left == null || leftColored == null
181
+ ? `<div class="diff-cell empty"></div>`
182
+ : `<div class="diff-cell ${pair.left.type}"><span class="gutter">${pair.left.oldLineNo ?? pair.left.newLineNo ?? ''}</span><span class="sign">${pair.left.type === 'removed' ? '-' : ' '}</span><span class="content">${leftColored}</span></div>`
183
+ const cellR =
184
+ pair.right == null || rightColored == null
185
+ ? `<div class="diff-cell empty"></div>`
186
+ : `<div class="diff-cell ${pair.right.type}"><span class="gutter">${pair.right.newLineNo ?? pair.right.oldLineNo ?? ''}</span><span class="sign">${pair.right.type === 'added' ? '+' : ' '}</span><span class="content">${rightColored}</span></div>`
187
+ return `<div class="diff-row split">${cellL}${cellR}</div>`
188
+ }
@@ -1,6 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { basename } from 'node:path'
3
+ import type { Highlighter } from 'shiki'
3
4
  import { parseUnifiedDiffToHunks, splitDiffByFile } from './diff-utils.ts'
5
+ import { getHighlighter } from './highlight.ts'
4
6
  import { renderActionBar } from './render-action-bar.ts'
5
7
  import { renderSplitDiff, renderUnifiedDiff } from './render-diff.ts'
6
8
  import { renderFileTree } from './render-file-tree.ts'
@@ -26,6 +28,8 @@ export type RenderFindingsInput = {
26
28
  files?: PrFileEntry[]
27
29
  /** Raw unified diff text for the PR. */
28
30
  diff?: string
31
+ /** Shiki highlighter, prepared by the caller. */
32
+ highlighter: Highlighter
29
33
  }
30
34
 
31
35
  function esc(s: string): string {
@@ -78,6 +82,7 @@ function filePane(opts: {
78
82
  findings: ReviewFinding[]
79
83
  postStatus: PostStatusMap
80
84
  selectedIds: Set<string>
85
+ highlighter: Highlighter
81
86
  }): string {
82
87
  const hunks = parseUnifiedDiffToHunks(opts.fileDiff)
83
88
  const unified = renderUnifiedDiff({
@@ -85,12 +90,16 @@ function filePane(opts: {
85
90
  findings: opts.findings,
86
91
  postStatus: opts.postStatus,
87
92
  selectedIds: opts.selectedIds,
93
+ highlighter: opts.highlighter,
94
+ file: opts.file.path,
88
95
  })
89
96
  const split = renderSplitDiff({
90
97
  hunks,
91
98
  findings: opts.findings,
92
99
  postStatus: opts.postStatus,
93
100
  selectedIds: opts.selectedIds,
101
+ highlighter: opts.highlighter,
102
+ file: opts.file.path,
94
103
  })
95
104
  const findingsCount = opts.findings.length
96
105
  // Findings can't be placed inline when (a) the file isn't in the diff at all
@@ -108,7 +117,7 @@ function filePane(opts: {
108
117
  <span class="unplaced-banner-label">${unplaced.length} finding${unplaced.length === 1 ? '' : 's'} not anchored to the diff</span>
109
118
  <span class="unplaced-banner-hint">${hunks.length === 0 ? 'this file is not in the PR diff snapshot' : 'line falls outside the visible hunks'}</span>
110
119
  </div>
111
- ${renderIssuesList({ findings: unplaced, postStatus: opts.postStatus, selectedIds: opts.selectedIds })}
120
+ ${renderIssuesList({ findings: unplaced, postStatus: opts.postStatus, selectedIds: opts.selectedIds, highlighter: opts.highlighter })}
112
121
  </div>`
113
122
  : ''
114
123
  return `<section class="file-pane" data-file-pane="${esc(opts.file.path)}" hidden>
@@ -135,6 +144,7 @@ function overviewPane(opts: {
135
144
  findings: ReviewFinding[]
136
145
  postStatus: PostStatusMap
137
146
  selectedIds: Set<string>
147
+ highlighter: Highlighter
138
148
  }): string {
139
149
  const general = opts.findings.filter((f) => !f.file)
140
150
  if (general.length === 0) {
@@ -143,7 +153,7 @@ function overviewPane(opts: {
143
153
  </section>`
144
154
  }
145
155
  return `<section class="overview-pane" data-file-pane="">
146
- ${renderIssuesList({ findings: general, postStatus: opts.postStatus, selectedIds: opts.selectedIds })}
156
+ ${renderIssuesList({ findings: general, postStatus: opts.postStatus, selectedIds: opts.selectedIds, highlighter: opts.highlighter })}
147
157
  </section>`
148
158
  }
149
159
 
@@ -183,6 +193,7 @@ ${prHeader(input)}
183
193
  findings: findingsInFile,
184
194
  postStatus: input.postStatus,
185
195
  selectedIds,
196
+ highlighter: input.highlighter,
186
197
  })
187
198
  })
188
199
  .join('\n')
@@ -191,12 +202,14 @@ ${prHeader(input)}
191
202
  findings: input.findings,
192
203
  postStatus: input.postStatus,
193
204
  selectedIds,
205
+ highlighter: input.highlighter,
194
206
  })
195
207
  const tree = renderFileTree({ files, findings: input.findings })
196
208
  const issues = renderIssuesList({
197
209
  findings: input.findings,
198
210
  postStatus: input.postStatus,
199
211
  selectedIds,
212
+ highlighter: input.highlighter,
200
213
  })
201
214
  const actionBar = renderActionBar({ findings: input.findings })
202
215
 
@@ -229,15 +242,16 @@ ${actionBar}
229
242
  }
230
243
 
231
244
  export async function renderFindingsToDisk(
232
- input: RenderFindingsInput,
245
+ input: Omit<RenderFindingsInput, 'highlighter'>,
233
246
  outPath: string,
234
247
  ): Promise<void> {
235
248
  const stylesPath = new URL('../templates/styles.css', import.meta.url).pathname
236
249
  const helperPath = new URL('./helper.js', import.meta.url).pathname
237
250
  const css = await readFile(stylesPath, 'utf8')
238
251
  const helper = await readFile(helperPath, 'utf8')
252
+ const highlighter = await getHighlighter()
239
253
  const derived = basename(outPath.replace(/\/screen\/.+$/, ''))
240
- const html = renderFindingsHtml({ ...input, runId: input.runId ?? derived })
254
+ const html = renderFindingsHtml({ ...input, runId: input.runId ?? derived, highlighter })
241
255
  .replace(
242
256
  'data:text/css;base64,STYLES_INLINE',
243
257
  `data:text/css;base64,${Buffer.from(css).toString('base64')}`,
@@ -1,3 +1,4 @@
1
+ import type { Highlighter } from 'shiki'
1
2
  import { renderAnnotation } from './render-annotation.ts'
2
3
  import type { PostStatusMap } from './types.ts'
3
4
  import { isSuggestion, type ReviewFinding, SEVERITIES, type Severity } from './types.ts'
@@ -26,6 +27,7 @@ export type RenderIssuesListInput = {
26
27
  findings: ReviewFinding[]
27
28
  postStatus: PostStatusMap
28
29
  selectedIds: Set<string>
30
+ highlighter: Highlighter
29
31
  }
30
32
 
31
33
  function severityCounts(findings: ReviewFinding[]): Map<Severity, number> {
@@ -35,7 +37,7 @@ function severityCounts(findings: ReviewFinding[]): Map<Severity, number> {
35
37
  }
36
38
 
37
39
  export function renderIssuesList(input: RenderIssuesListInput): string {
38
- const { findings, postStatus, selectedIds } = input
40
+ const { findings, postStatus, selectedIds, highlighter } = input
39
41
  const actionable = findings.filter((f) => !isSuggestion(f))
40
42
  const suggestions = findings.filter((f) => isSuggestion(f))
41
43
  const counts = severityCounts(findings)
@@ -61,6 +63,7 @@ export function renderIssuesList(input: RenderIssuesListInput): string {
61
63
  posted: status === 'posted',
62
64
  failed,
63
65
  asCard: true,
66
+ highlighter,
64
67
  })
65
68
  })
66
69
  .join('\n')