@cfbender/cesium 0.5.1 → 0.5.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.2 — 2026-05-12
4
+
5
+ A 16th block type — `diff` — that renders a beautiful side-by-side
6
+ before/after code diff with curved SVG bezier connectors between the two
7
+ columns. JetBrains diff-viewer aesthetic. Plus tooling improvements for
8
+ hot-reload visual iteration during plugin development.
9
+
10
+ - **feat:** New `diff` block type. Two input arms (XOR):
11
+ - `patch`: literal unified diff string (e.g. from `git diff` output)
12
+ - `before`/`after`: paired text strings; server runs Myers O(ND) line
13
+ diff to compute the change set
14
+ - **feat:** Per-line shiki syntax highlighting is preserved through the
15
+ diff. The renderer recomposes before-side and after-side text, runs each
16
+ through `highlightCode`, then zips the styled line spans back into the
17
+ diff line list — so multi-line constructs (template strings, block
18
+ comments) tokenize correctly.
19
+ - **feat:** Visual rendering:
20
+ - Three-column grid (1fr | 60px | 1fr) with line numbers per side
21
+ - Subtle red/green line-tint backgrounds on remove/add lines
22
+ - 60px-wide SVG connector column draws semi-transparent cubic-bezier
23
+ ribbons connecting each remove region on the left to the corresponding
24
+ add region on the right; pure adds collapse to a teardrop pointing
25
+ into the left, pure removes mirror
26
+ - Optional file-header strip with filename + `+N -M` stats
27
+ - Optional caption strip below
28
+ - 720px breakpoint collapses to single-column with connector hidden
29
+ - **feat:** Theme tokens `--diff-add`, `--diff-remove`, `--diff-change`
30
+ added to all seven palette presets so colors fit each preset's character
31
+ (claret rose, warm clay, cool blue, etc.).
32
+ - **fix:** Diff connector SVG now matches the `padding: 8px 0` of the
33
+ side columns so the bezier paths line up exactly with their target
34
+ regions instead of sitting 8px high.
35
+ - **chore:** Project-local opencode config tracked: `.opencode/opencode.json`,
36
+ `.opencode/plugins/cesium.ts` (dev-loop shim that loads the working
37
+ tree's source instead of the published npm package), and
38
+ `.opencode/skills/cesium-preview/SKILL.md` (hot-reload visual iteration
39
+ workflow that bypasses the stale plugin host by importing render code
40
+ directly via bun and writing to /tmp).
41
+ - **chore:** `scripts/dogfood-diff.ts` reference preview script for the
42
+ diff block. Useful template for previewing other block work.
43
+ - **chore:** Apply oxfmt to 30 files that drifted out of format compliance
44
+ in v0.5.1 (no CI lint gate caught it). Pure cosmetic — line-wrapping,
45
+ italic style normalization, key ordering. No behavior changes.
46
+
3
47
  ## v0.5.1 — 2026-05-12
4
48
 
5
49
  Server-side syntax highlighting for `code` blocks via shiki, custom claret
@@ -10,11 +54,11 @@ longer kill the plugin host process.
10
54
  - **fix (critical):** Lazy-started cesium server now runs as a detached
11
55
  subprocess. Previously `ensureRunning` (called from publish/ask plugin
12
56
  paths) ran `Bun.serve()` in-process and wrote `pid: process.pid` to the PID
13
- file — meaning that PID was the *plugin host* (e.g. opencode). Any
57
+ file — meaning that PID was the _plugin host_ (e.g. opencode). Any
14
58
  invocation of `cesium stop` (CLI, tool, or test) would signal the host
15
59
  process and kill it. Now lazy-start spawns `bun run cli serve` as a
16
60
  detached child; the PID file points at that child. Foreground `cesium
17
- serve` still runs in-process (correct for its semantics).
61
+ serve` still runs in-process (correct for its semantics).
18
62
  - **api:** Split `ensureRunning` into `runServerForeground` (in-process, for
19
63
  the foreground CLI) and `ensureServerRunning` (detached subprocess, for
20
64
  plugins). `ensureRunning` is kept as a backward-compat alias for
@@ -82,7 +126,7 @@ tokens, more on heavily structured artifacts.
82
126
  surfaced as a small badge on index cards.
83
127
  - **feat:** Framework CSS extended with rules for every block-renderer
84
128
  pattern: `dl.kv` (2-column grid), `.pill-row`, `.check-list`, `<hr
85
- data-label>`, `figure.code`, timeline-item internals, `.lede`, plus
129
+ data-label>`, `figure.code`, timeline-item internals, `.lede`, plus
86
130
  `.diagram svg text { fill: currentColor }` so SVGs inherit theme color.
87
131
  - **feat:** `escapeHtml` and `escapeAttr` throw a clear error on non-string
88
132
  input instead of crashing inside `.replace()`.
package/README.md CHANGED
@@ -382,15 +382,15 @@ state directory, hostname, and theme settings flow through.
382
382
 
383
383
  Optional `~/.config/opencode/cesium.json`:
384
384
 
385
- | Key | Type | Default | Description |
386
- | --------------- | ------ | ----------------------- | ---------------------------------------------------------------------------------------- |
387
- | `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live |
388
- | `port` | number | `3030` | First port to try for the local HTTP server |
389
- | `portMax` | number | `3050` | Upper bound when scanning for free ports |
390
- | `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN |
385
+ | Key | Type | Default | Description |
386
+ | --------------- | ------ | ----------------------- | ------------------------------------------------------------------------------------------- |
387
+ | `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live |
388
+ | `port` | number | `3030` | First port to try for the local HTTP server |
389
+ | `portMax` | number | `3050` | Upper bound when scanning for free ports |
390
+ | `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN |
391
391
  | `idleTimeoutMs` | number | `1800000` | Plugin server idle-shutdown threshold (30 min). Does not apply to foreground `cesium serve` |
392
- | `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`) |
393
- | `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset) |
392
+ | `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`) |
393
+ | `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset) |
394
394
 
395
395
  Environment overrides: `CESIUM_PORT`, `CESIUM_STATE_DIR`, `CESIUM_HOSTNAME`, `CESIUM_THEME_PRESET`.
396
396
 
package/package.json CHANGED
@@ -1,29 +1,27 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Beautiful self-contained HTML artifacts from your opencode agent.",
5
- "license": "MIT",
6
- "author": "Cody Bender",
5
+ "keywords": [
6
+ "agent",
7
+ "artifact",
8
+ "claret",
9
+ "cli",
10
+ "html",
11
+ "opencode",
12
+ "plugin"
13
+ ],
7
14
  "homepage": "https://github.com/cfbender/cesium#readme",
8
15
  "bugs": "https://github.com/cfbender/cesium/issues",
16
+ "license": "MIT",
17
+ "author": "Cody Bender",
9
18
  "repository": {
10
19
  "type": "git",
11
20
  "url": "git+https://github.com/cfbender/cesium.git"
12
21
  },
13
- "keywords": [
14
- "opencode",
15
- "agent",
16
- "html",
17
- "artifact",
18
- "plugin",
19
- "cli",
20
- "claret"
21
- ],
22
22
  "bin": {
23
23
  "cesium": "./src/cli/index.ts"
24
24
  },
25
- "type": "module",
26
- "main": "src/index.ts",
27
25
  "files": [
28
26
  "src",
29
27
  "assets/styleguide.html",
@@ -31,12 +29,11 @@
31
29
  "ARCHITECTURE.md",
32
30
  "CHANGELOG.md"
33
31
  ],
32
+ "type": "module",
33
+ "main": "src/index.ts",
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
- "engines": {
38
- "bun": ">=1.0.0"
39
- },
40
37
  "scripts": {
41
38
  "test": "bun test",
42
39
  "typecheck": "tsc --noEmit",
@@ -58,5 +55,8 @@
58
55
  "oxfmt": "^0.48.0",
59
56
  "oxlint": "^1.63.0",
60
57
  "typescript": "^5.4.0"
58
+ },
59
+ "engines": {
60
+ "bun": ">=1.0.0"
61
61
  }
62
62
  }
@@ -41,8 +41,7 @@ function parseDuration(input: string): number | null {
41
41
  const n = parseFloat(match[1] ?? "");
42
42
  if (!isFinite(n) || n < 0) return null;
43
43
  const unit = match[2] ?? "ms";
44
- const mul =
45
- unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
44
+ const mul = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
46
45
  return Math.floor(n * mul);
47
46
  }
48
47
 
package/src/index.ts CHANGED
@@ -17,7 +17,10 @@ const rawFragment = await readFile(
17
17
  "utf8",
18
18
  );
19
19
 
20
- const PROMPT_FRAGMENT = rawFragment.replace("{{BLOCK_FIELD_REFERENCE}}", generateBlockFieldReference());
20
+ const PROMPT_FRAGMENT = rawFragment.replace(
21
+ "{{BLOCK_FIELD_REFERENCE}}",
22
+ generateBlockFieldReference(),
23
+ );
21
24
 
22
25
  export const CesiumPlugin: Plugin = async (ctx): Promise<Hooks> => {
23
26
  return {
@@ -86,8 +86,8 @@ export function generateBlockFieldReference(): string {
86
86
  lines.push("");
87
87
  lines.push(
88
88
  "All `markdown` fields support `**bold**`, `*italic*`, `` `code` ``, lists, blockquotes, " +
89
- "and the safelisted inline tags `<kbd>`, `<span class=\"pill\">`, `<span class=\"tag\">`. " +
90
- "External URLs in links render as plain text.",
89
+ 'and the safelisted inline tags `<kbd>`, `<span class="pill">`, `<span class="tag">`. ' +
90
+ "External URLs in links render as plain text.",
91
91
  );
92
92
 
93
93
  return lines.join("\n");
@@ -20,22 +20,52 @@ You have access to six tools:
20
20
  ### Example
21
21
 
22
22
  ```json
23
- { "title": "Migration Guide", "kind": "plan", "blocks": [
24
- { "type": "hero", "eyebrow": "v2", "title": "Migration Guide",
25
- "meta": [{ "k": "Status", "v": "Draft" }, { "k": "Owner", "v": "platform" }] },
26
- { "type": "tldr", "markdown": "**Summary:** Update one import path and bump the SDK." },
27
- { "type": "section", "title": "What Changed", "children": [
28
- { "type": "prose", "markdown": "The `auth` module is now a standalone package." },
29
- { "type": "callout", "variant": "warn", "markdown": "Change `sdk/auth` imports before upgrading." }
30
- ]},
31
- { "type": "risk_table", "rows": [
32
- { "risk": "Missed imports", "likelihood": "medium", "impact": "high", "mitigation": "Run codemods." }
33
- ]},
34
- { "type": "timeline", "items": [
35
- { "label": "Phase 1", "text": "Audit existing imports", "date": "2026-06-01" },
36
- { "label": "Phase 2", "text": "Run migration script" }
37
- ]}
38
- ]}
23
+ {
24
+ "title": "Migration Guide",
25
+ "kind": "plan",
26
+ "blocks": [
27
+ {
28
+ "type": "hero",
29
+ "eyebrow": "v2",
30
+ "title": "Migration Guide",
31
+ "meta": [
32
+ { "k": "Status", "v": "Draft" },
33
+ { "k": "Owner", "v": "platform" }
34
+ ]
35
+ },
36
+ { "type": "tldr", "markdown": "**Summary:** Update one import path and bump the SDK." },
37
+ {
38
+ "type": "section",
39
+ "title": "What Changed",
40
+ "children": [
41
+ { "type": "prose", "markdown": "The `auth` module is now a standalone package." },
42
+ {
43
+ "type": "callout",
44
+ "variant": "warn",
45
+ "markdown": "Change `sdk/auth` imports before upgrading."
46
+ }
47
+ ]
48
+ },
49
+ {
50
+ "type": "risk_table",
51
+ "rows": [
52
+ {
53
+ "risk": "Missed imports",
54
+ "likelihood": "medium",
55
+ "impact": "high",
56
+ "mitigation": "Run codemods."
57
+ }
58
+ ]
59
+ },
60
+ {
61
+ "type": "timeline",
62
+ "items": [
63
+ { "label": "Phase 1", "text": "Audit existing imports", "date": "2026-06-01" },
64
+ { "label": "Phase 2", "text": "Run migration script" }
65
+ ]
66
+ }
67
+ ]
68
+ }
39
69
  ```
40
70
 
41
71
  ## Quick block reference
@@ -17,6 +17,7 @@ import { meta as pillRowMeta } from "./renderers/pill-row.ts";
17
17
  import { meta as dividerMeta } from "./renderers/divider.ts";
18
18
  import { meta as diagramMeta } from "./renderers/diagram.ts";
19
19
  import { meta as rawHtmlMeta } from "./renderers/raw-html.ts";
20
+ import { meta as diffMeta } from "./renderers/diff.ts";
20
21
 
21
22
  export const blockCatalog: Record<Block["type"], BlockMeta> = {
22
23
  hero: heroMeta,
@@ -34,6 +35,7 @@ export const blockCatalog: Record<Block["type"], BlockMeta> = {
34
35
  divider: dividerMeta,
35
36
  diagram: diagramMeta,
36
37
  raw_html: rawHtmlMeta,
38
+ diff: diffMeta,
37
39
  };
38
40
 
39
41
  export const blockTypes = Object.keys(blockCatalog) as Array<Block["type"]>;
@@ -0,0 +1,221 @@
1
+ // Myers O(ND) line-level diff algorithm.
2
+ // src/render/blocks/diff/myers.ts
3
+ //
4
+ // Re-implemented from scratch — no external dependencies.
5
+ // Reference: "An O(ND) Difference Algorithm and Its Variations" — Eugene W. Myers (1986).
6
+ //
7
+ // Implementation follows the standard "trace + backtrack" pattern from:
8
+ // https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/
9
+
10
+ import type { DiffLine } from "./parse-unified.ts";
11
+
12
+ export type { DiffLine };
13
+
14
+ /**
15
+ * Split a string into lines, trimming a single trailing empty element when the
16
+ * input ends with "\n" (so clean files don't produce a phantom blank final line).
17
+ */
18
+ function splitLines(text: string): string[] {
19
+ if (text === "") return [];
20
+ const lines = text.split("\n");
21
+ // Trim trailing phantom empty line produced by a trailing newline
22
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
23
+ lines.pop();
24
+ }
25
+ return lines;
26
+ }
27
+
28
+ // ─── Myers forward pass ───────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Run the Myers forward pass and return the trace (one v[] snapshot per d).
32
+ * v[k + offset] = furthest row reached on diagonal k at this step.
33
+ */
34
+ function myersTrace(a: string[], b: string[]): number[][] {
35
+ const N = a.length;
36
+ const M = b.length;
37
+ const MAX = N + M;
38
+ const offset = MAX;
39
+
40
+ const v: number[] = Array.from({ length: 2 * MAX + 2 }, () => 0);
41
+ v[offset + 1] = 0;
42
+
43
+ const trace: number[][] = [];
44
+
45
+ for (let d = 0; d <= MAX; d++) {
46
+ for (let k = -d; k <= d; k += 2) {
47
+ const ki = k + offset;
48
+ const vKm1 = v[ki - 1] ?? 0;
49
+ const vKp1 = v[ki + 1] ?? 0;
50
+
51
+ let x: number;
52
+ if (k === -d || (k !== d && vKm1 < vKp1)) {
53
+ x = vKp1; // down: insert
54
+ } else {
55
+ x = vKm1 + 1; // right: delete
56
+ }
57
+
58
+ let y = x - k;
59
+
60
+ // Follow the diagonal (matches)
61
+ while (x < N && y < M && a[x] === b[y]) {
62
+ x++;
63
+ y++;
64
+ }
65
+
66
+ v[ki] = x;
67
+
68
+ if (x >= N && y >= M) {
69
+ trace.push(v.slice());
70
+ return trace;
71
+ }
72
+ }
73
+
74
+ trace.push(v.slice());
75
+ }
76
+
77
+ return trace;
78
+ }
79
+
80
+ // ─── Myers backtrack ──────────────────────────────────────────────────────────
81
+
82
+ type Edit =
83
+ | { op: "keep"; aIdx: number; bIdx: number }
84
+ | { op: "delete"; aIdx: number }
85
+ | { op: "insert"; bIdx: number };
86
+
87
+ /**
88
+ * Backtrack through the trace to produce the edit script.
89
+ * Reconstructs the path from (N, M) back to (0, 0).
90
+ */
91
+ function backtrack(a: string[], b: string[], trace: number[][]): Edit[] {
92
+ const MAX = a.length + b.length;
93
+ const offset = MAX;
94
+ const edits: Edit[] = [];
95
+
96
+ let x = a.length;
97
+ let y = b.length;
98
+
99
+ // Walk backwards through d steps
100
+ for (let d = trace.length - 1; d > 0; d--) {
101
+ const vPrev = trace[d - 1] ?? [];
102
+ const k = x - y;
103
+ const ki = k + offset;
104
+
105
+ const vPrevKm1 = vPrev[ki - 1] ?? 0;
106
+ const vPrevKp1 = vPrev[ki + 1] ?? 0;
107
+
108
+ // Determine which diagonal we came from
109
+ let prevK: number;
110
+ if (k === -d || (k !== d && vPrevKm1 < vPrevKp1)) {
111
+ prevK = k + 1; // came via down (insert)
112
+ } else {
113
+ prevK = k - 1; // came via right (delete)
114
+ }
115
+
116
+ const prevX = vPrev[prevK + offset] ?? 0;
117
+ const prevY = prevX - prevK;
118
+
119
+ // Retrace the snake (diagonal matches) from (prevX, prevY) to (x, y)
120
+ // but skip the single edit step itself.
121
+ // After the edit step, we were at:
122
+ // insert: (prevX, prevY + 1)
123
+ // delete: (prevX + 1, prevY)
124
+ if (prevK === k + 1) {
125
+ // insert: moved down, x stays same, y += 1
126
+ const snakeX = prevX;
127
+ // snake goes from (snakeX, prevY+1) to (x, y)
128
+ for (let sx = x - 1; sx >= snakeX; sx--) {
129
+ const sy = sx - k;
130
+ edits.push({ op: "keep", aIdx: sx, bIdx: sy });
131
+ }
132
+ edits.push({ op: "insert", bIdx: prevY });
133
+ } else {
134
+ // delete: moved right, x += 1, y stays same
135
+ const snakeX = prevX + 1;
136
+ // snake goes from (snakeX, prevY) to (x, y)
137
+ for (let sx = x - 1; sx >= snakeX; sx--) {
138
+ const sy = sx - k;
139
+ edits.push({ op: "keep", aIdx: sx, bIdx: sy });
140
+ }
141
+ edits.push({ op: "delete", aIdx: prevX });
142
+ }
143
+
144
+ x = prevX;
145
+ y = prevY;
146
+ }
147
+
148
+ // Remaining snake at d=0: (0,0) to (x,y)
149
+ for (let sx = x - 1; sx >= 0; sx--) {
150
+ const sy = sx; // k=0 at the start
151
+ edits.push({ op: "keep", aIdx: sx, bIdx: sy });
152
+ }
153
+
154
+ edits.reverse();
155
+ return edits;
156
+ }
157
+
158
+ function myersDiff(a: string[], b: string[]): Edit[] {
159
+ if (a.length === 0 && b.length === 0) return [];
160
+ if (a.length === 0) return b.map((_, i) => ({ op: "insert" as const, bIdx: i }));
161
+ if (b.length === 0) return a.map((_, i) => ({ op: "delete" as const, aIdx: i }));
162
+
163
+ const trace = myersTrace(a, b);
164
+ return backtrack(a, b, trace);
165
+ }
166
+
167
+ // ─── Public API ───────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Compute a line-level diff between two text strings.
171
+ * Returns a flat DiffLine[] with 1-indexed line numbers per side.
172
+ * No hunk-sep entries — single contiguous diff.
173
+ */
174
+ export function diffLines(before: string, after: string): DiffLine[] {
175
+ const aLines = splitLines(before);
176
+ const bLines = splitLines(after);
177
+
178
+ const edits = myersDiff(aLines, bLines);
179
+
180
+ let beforeLine = 1;
181
+ let afterLine = 1;
182
+
183
+ return edits.map((edit): DiffLine => {
184
+ switch (edit.op) {
185
+ case "keep": {
186
+ const text = aLines[edit.aIdx] ?? "";
187
+ const entry: DiffLine = {
188
+ kind: "context",
189
+ text,
190
+ beforeLineNum: beforeLine,
191
+ afterLineNum: afterLine,
192
+ };
193
+ beforeLine++;
194
+ afterLine++;
195
+ return entry;
196
+ }
197
+ case "delete": {
198
+ const text = aLines[edit.aIdx] ?? "";
199
+ const entry: DiffLine = {
200
+ kind: "remove",
201
+ text,
202
+ beforeLineNum: beforeLine,
203
+ afterLineNum: null,
204
+ };
205
+ beforeLine++;
206
+ return entry;
207
+ }
208
+ case "insert": {
209
+ const text = bLines[edit.bIdx] ?? "";
210
+ const entry: DiffLine = {
211
+ kind: "add",
212
+ text,
213
+ beforeLineNum: null,
214
+ afterLineNum: afterLine,
215
+ };
216
+ afterLine++;
217
+ return entry;
218
+ }
219
+ }
220
+ });
221
+ }
@@ -0,0 +1,101 @@
1
+ // Unified diff parser — converts a unified patch string to a DiffEntry[].
2
+ // src/render/blocks/diff/parse-unified.ts
3
+
4
+ export type DiffLine = {
5
+ kind: "context" | "add" | "remove";
6
+ text: string; // raw text, no leading +/- prefix
7
+ beforeLineNum: number | null; // 1-indexed line in "before" file, null for adds
8
+ afterLineNum: number | null; // 1-indexed line in "after" file, null for removes
9
+ };
10
+
11
+ export type DiffEntry = DiffLine | { kind: "hunk-sep"; oldStart: number; newStart: number };
12
+
13
+ // Matches: @@ -<oldStart>[,<oldLines>] +<newStart>[,<newLines>] @@
14
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
15
+
16
+ /**
17
+ * Parse a unified diff patch string into a DiffEntry[].
18
+ * Returns null if no hunk header is found (caller falls back to plaintext).
19
+ */
20
+ export function parseUnifiedDiff(patch: string): DiffEntry[] | null {
21
+ const lines = patch.split("\n");
22
+ const result: DiffEntry[] = [];
23
+ let foundHunk = false;
24
+ let firstHunk = true;
25
+
26
+ // Counters for current hunk position
27
+ let beforeLine = 0;
28
+ let afterLine = 0;
29
+
30
+ for (const line of lines) {
31
+ // Skip file header lines (--- / +++ at the very top)
32
+ if (line.startsWith("--- ") || line.startsWith("+++ ")) {
33
+ continue;
34
+ }
35
+
36
+ // Check for hunk header
37
+ const hunkMatch = HUNK_HEADER.exec(line);
38
+ if (hunkMatch !== null) {
39
+ const oldStart = parseInt(hunkMatch[1] ?? "1", 10);
40
+ const newStart = parseInt(hunkMatch[3] ?? "1", 10);
41
+
42
+ if (!firstHunk) {
43
+ // Emit hunk separator between hunks
44
+ result.push({ kind: "hunk-sep", oldStart, newStart });
45
+ }
46
+ firstHunk = false;
47
+ foundHunk = true;
48
+
49
+ beforeLine = oldStart;
50
+ afterLine = newStart;
51
+ continue;
52
+ }
53
+
54
+ if (!foundHunk) {
55
+ // Haven't seen a hunk header yet — skip pre-header lines
56
+ continue;
57
+ }
58
+
59
+ // Skip "" markers
60
+ if (line.startsWith("\\")) {
61
+ continue;
62
+ }
63
+
64
+ const prefix = line[0];
65
+ const text = line.slice(1);
66
+
67
+ if (prefix === " ") {
68
+ // Context line — appears on both sides
69
+ result.push({
70
+ kind: "context",
71
+ text,
72
+ beforeLineNum: beforeLine,
73
+ afterLineNum: afterLine,
74
+ });
75
+ beforeLine++;
76
+ afterLine++;
77
+ } else if (prefix === "-") {
78
+ // Removed line — only in "before"
79
+ result.push({
80
+ kind: "remove",
81
+ text,
82
+ beforeLineNum: beforeLine,
83
+ afterLineNum: null,
84
+ });
85
+ beforeLine++;
86
+ } else if (prefix === "+") {
87
+ // Added line — only in "after"
88
+ result.push({
89
+ kind: "add",
90
+ text,
91
+ beforeLineNum: null,
92
+ afterLineNum: afterLine,
93
+ });
94
+ afterLine++;
95
+ }
96
+ // Any other prefix: skip (e.g. empty lines after hunk body)
97
+ }
98
+
99
+ if (!foundHunk) return null;
100
+ return result;
101
+ }
@@ -13,11 +13,7 @@ import { THEME_PRESETS, isThemePresetName } from "../../render/theme.ts";
13
13
 
14
14
  // ─── Highlight theme type ─────────────────────────────────────────────────────
15
15
 
16
- export type HighlightTheme =
17
- | "claret-dark"
18
- | "claret-light"
19
- | "vitesse-dark"
20
- | "vitesse-light";
16
+ export type HighlightTheme = "claret-dark" | "claret-light" | "vitesse-dark" | "vitesse-light";
21
17
 
22
18
  // ─── Supported languages ─────────────────────────────────────────────────────
23
19
 
@@ -88,12 +84,13 @@ export function resolveHighlightTheme(cesiumThemeName: string | undefined): High
88
84
  */
89
85
  function isHexDark(hex: string): boolean {
90
86
  const clean = hex.replace("#", "");
91
- const full = clean.length === 3
92
- ? clean
93
- .split("")
94
- .map((c) => c + c)
95
- .join("")
96
- : clean;
87
+ const full =
88
+ clean.length === 3
89
+ ? clean
90
+ .split("")
91
+ .map((c) => c + c)
92
+ .join("")
93
+ : clean;
97
94
  const r = parseInt(full.slice(0, 2), 16);
98
95
  const g = parseInt(full.slice(2, 4), 16);
99
96
  const b = parseInt(full.slice(4, 6), 16);