@cfbender/cesium 0.5.1 → 0.6.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +97 -3
  2. package/README.md +8 -8
  3. package/package.json +19 -17
  4. package/src/cli/commands/ls.ts +62 -65
  5. package/src/cli/commands/open.ts +47 -62
  6. package/src/cli/commands/prune.ts +59 -71
  7. package/src/cli/commands/restart.ts +100 -12
  8. package/src/cli/commands/serve.ts +119 -116
  9. package/src/cli/commands/stop.ts +51 -84
  10. package/src/cli/commands/theme.ts +54 -92
  11. package/src/cli/index.ts +17 -70
  12. package/src/index.ts +4 -1
  13. package/src/prompt/field-reference.ts +2 -2
  14. package/src/prompt/system-fragment.md +46 -16
  15. package/src/render/blocks/catalog.ts +2 -0
  16. package/src/render/blocks/diff/myers.ts +221 -0
  17. package/src/render/blocks/diff/parse-unified.ts +101 -0
  18. package/src/render/blocks/highlight.ts +8 -11
  19. package/src/render/blocks/markdown.ts +28 -7
  20. package/src/render/blocks/render.ts +3 -0
  21. package/src/render/blocks/renderers/code.ts +1 -3
  22. package/src/render/blocks/renderers/compare-table.ts +3 -4
  23. package/src/render/blocks/renderers/diagram.ts +2 -5
  24. package/src/render/blocks/renderers/diff.ts +378 -0
  25. package/src/render/blocks/renderers/prose.ts +1 -2
  26. package/src/render/blocks/renderers/timeline.ts +2 -1
  27. package/src/render/blocks/themes/claret-dark.ts +1 -6
  28. package/src/render/blocks/themes/claret-light.ts +1 -6
  29. package/src/render/blocks/types.ts +13 -1
  30. package/src/render/blocks/validate-block.ts +19 -9
  31. package/src/render/theme.ts +149 -0
  32. package/src/render/validate.ts +53 -9
  33. package/src/server/api.ts +112 -124
  34. package/src/server/favicon.ts +8 -16
  35. package/src/server/http.ts +101 -106
  36. package/src/server/lifecycle.ts +12 -6
  37. package/src/storage/assets.ts +8 -10
  38. package/src/storage/index-gen.ts +2 -3
  39. package/src/storage/theme-write.ts +17 -3
  40. package/src/tools/publish.ts +1 -3
  41. package/src/tools/styleguide.ts +3 -7
  42. package/src/tools/wait.ts +1 -0
@@ -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);
@@ -11,8 +11,7 @@ import { escapeHtml, escapeAttr } from "./escape.ts";
11
11
 
12
12
  // ─── Safelist placeholder pass ───────────────────────────────────────────────
13
13
 
14
- const SAFELIST_RE =
15
- /<(kbd)>(.*?)<\/kbd>|<span\s+class="(pill|tag)">(.*?)<\/span>/gi;
14
+ const SAFELIST_RE = /<(kbd)>(.*?)<\/kbd>|<span\s+class="(pill|tag)">(.*?)<\/span>/gi;
16
15
 
17
16
  function extractSafelist(input: string): { text: string; map: Map<string, string> } {
18
17
  const map = new Map<string, string>();
@@ -72,7 +71,10 @@ function renderInline(text: string): string {
72
71
  // **bold**
73
72
  working = working.replace(/\*\*(.+?)\*\*/g, (_m, inner: string) => `<strong>${inner}</strong>`);
74
73
  // *italic* (not preceded by another *)
75
- working = working.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, (_m, inner: string) => `<em>${inner}</em>`);
74
+ working = working.replace(
75
+ /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g,
76
+ (_m, inner: string) => `<em>${inner}</em>`,
77
+ );
76
78
  // [text](href) — only relative/anchor hrefs; external → plain text
77
79
  working = working.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText: string, href: string) => {
78
80
  if (RELATIVE_HREF_RE.test(href)) {
@@ -159,7 +161,11 @@ export function renderMarkdown(md: string): string {
159
161
  // Bullet list
160
162
  if (isBullet(line)) {
161
163
  const items: string[] = [];
162
- while (i < lines.length && (isBullet(lines[i] ?? "") || (!isBlank(lines[i] ?? "") && /^[ \t]{2,}/.test(lines[i] ?? "")))) {
164
+ while (
165
+ i < lines.length &&
166
+ (isBullet(lines[i] ?? "") ||
167
+ (!isBlank(lines[i] ?? "") && /^[ \t]{2,}/.test(lines[i] ?? "")))
168
+ ) {
163
169
  const cur = lines[i] ?? "";
164
170
  if (isBullet(cur)) {
165
171
  items.push(renderInline(applyHardBreak(getBulletContent(cur))));
@@ -173,7 +179,11 @@ export function renderMarkdown(md: string): string {
173
179
  // Ordered list
174
180
  if (isOrdered(line)) {
175
181
  const items: string[] = [];
176
- while (i < lines.length && (isOrdered(lines[i] ?? "") || (!isBlank(lines[i] ?? "") && /^[ \t]{2,}/.test(lines[i] ?? "")))) {
182
+ while (
183
+ i < lines.length &&
184
+ (isOrdered(lines[i] ?? "") ||
185
+ (!isBlank(lines[i] ?? "") && /^[ \t]{2,}/.test(lines[i] ?? "")))
186
+ ) {
177
187
  const cur = lines[i] ?? "";
178
188
  if (isOrdered(cur)) {
179
189
  items.push(renderInline(applyHardBreak(getOrderedContent(cur))));
@@ -187,7 +197,11 @@ export function renderMarkdown(md: string): string {
187
197
  // Blockquote
188
198
  if (isBlockquote(line)) {
189
199
  const bqLines: string[] = [];
190
- while (i < lines.length && (isBlockquote(lines[i] ?? "") || (!isBlank(lines[i] ?? "") && !/^[-*]/.test(lines[i] ?? "")))) {
200
+ while (
201
+ i < lines.length &&
202
+ (isBlockquote(lines[i] ?? "") ||
203
+ (!isBlank(lines[i] ?? "") && !/^[-*]/.test(lines[i] ?? "")))
204
+ ) {
191
205
  const cur = lines[i] ?? "";
192
206
  if (isBlockquote(cur)) {
193
207
  bqLines.push(renderInline(applyHardBreak(getBlockquoteContent(cur))));
@@ -202,7 +216,14 @@ export function renderMarkdown(md: string): string {
202
216
 
203
217
  // Paragraph — collect until blank or block-level
204
218
  const paraLines: string[] = [];
205
- while (i < lines.length && !isBlank(lines[i] ?? "") && !isHr(lines[i] ?? "") && !isBullet(lines[i] ?? "") && !isOrdered(lines[i] ?? "") && !isBlockquote(lines[i] ?? "")) {
219
+ while (
220
+ i < lines.length &&
221
+ !isBlank(lines[i] ?? "") &&
222
+ !isHr(lines[i] ?? "") &&
223
+ !isBullet(lines[i] ?? "") &&
224
+ !isOrdered(lines[i] ?? "") &&
225
+ !isBlockquote(lines[i] ?? "")
226
+ ) {
206
227
  paraLines.push(applyHardBreak(lines[i] ?? ""));
207
228
  i++;
208
229
  }
@@ -18,6 +18,7 @@ import { renderPillRow } from "./renderers/pill-row.ts";
18
18
  import { renderDivider } from "./renderers/divider.ts";
19
19
  import { renderDiagram } from "./renderers/diagram.ts";
20
20
  import { renderRawHtml } from "./renderers/raw-html.ts";
21
+ import { renderDiff } from "./renderers/diff.ts";
21
22
 
22
23
  /** Shared mutable counter — all section renderers increment this via the ctx ref. */
23
24
  export interface SectionCounter {
@@ -78,6 +79,8 @@ export async function renderBlock(block: Block, ctx: RenderCtx): Promise<string>
78
79
  return renderDiagram(block, ctx);
79
80
  case "raw_html":
80
81
  return renderRawHtml(block, ctx);
82
+ case "diff":
83
+ return renderDiff(block, ctx);
81
84
  }
82
85
  }
83
86
 
@@ -16,9 +16,7 @@ export async function renderCode(block: CodeBlock, ctx: RenderCtx): Promise<stri
16
16
  }
17
17
 
18
18
  const highlighted = await highlightCode(block.code, block.lang, ctx.highlightTheme);
19
- parts.push(
20
- ` <pre><code class="lang-${escapeAttr(block.lang)}">${highlighted}</code></pre>`,
21
- );
19
+ parts.push(` <pre><code class="lang-${escapeAttr(block.lang)}">${highlighted}</code></pre>`);
22
20
 
23
21
  return `<figure class="code">\n${parts.join("\n")}\n</figure>`;
24
22
  }
@@ -8,9 +8,7 @@ import { escapeHtml } from "../escape.ts";
8
8
  import { renderMarkdown } from "../markdown.ts";
9
9
 
10
10
  export function renderCompareTable(block: CompareTableBlock, _ctx: RenderCtx): string {
11
- const headerCells = block.headers
12
- .map((h) => ` <th>${escapeHtml(h)}</th>`)
13
- .join("\n");
11
+ const headerCells = block.headers.map((h) => ` <th>${escapeHtml(h)}</th>`).join("\n");
14
12
 
15
13
  const bodyRows = block.rows
16
14
  .map((row) => {
@@ -34,7 +32,8 @@ export function renderCompareTable(block: CompareTableBlock, _ctx: RenderCtx): s
34
32
 
35
33
  export const meta: BlockMeta = {
36
34
  type: "compare_table",
37
- description: "Bordered comparison grid. Headers define columns; rows must have matching cell count.",
35
+ description:
36
+ "Bordered comparison grid. Headers define columns; rows must have matching cell count.",
38
37
  schema: {
39
38
  type: "object",
40
39
  properties: {
@@ -25,7 +25,7 @@ export function renderDiagram(block: DiagramBlock, _ctx: RenderCtx): string {
25
25
  export const meta: BlockMeta = {
26
26
  type: "diagram",
27
27
  description:
28
- "Escape-hatch for inline SVG or bespoke HTML diagrams. Exactly one of svg or html required. Payload is scrubbed. For SVG, prefer fill=\"currentColor\" and stroke=\"currentColor\" so the diagram inherits the theme's text color. Use explicit colors only for emphasis (accents, warnings).",
28
+ 'Escape-hatch for inline SVG or bespoke HTML diagrams. Exactly one of svg or html required. Payload is scrubbed. For SVG, prefer fill="currentColor" and stroke="currentColor" so the diagram inherits the theme\'s text color. Use explicit colors only for emphasis (accents, warnings).',
29
29
  schema: {
30
30
  type: "object",
31
31
  properties: {
@@ -35,10 +35,7 @@ export const meta: BlockMeta = {
35
35
  html: { type: "string" },
36
36
  },
37
37
  required: ["type"],
38
- oneOf: [
39
- { required: ["svg"] },
40
- { required: ["html"] },
41
- ],
38
+ oneOf: [{ required: ["svg"] }, { required: ["html"] }],
42
39
  },
43
40
  example: {
44
41
  type: "diagram",