@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 +47 -3
- package/README.md +8 -8
- package/package.json +17 -17
- package/src/cli/commands/serve.ts +1 -2
- package/src/index.ts +4 -1
- package/src/prompt/field-reference.ts +2 -2
- package/src/prompt/system-fragment.md +46 -16
- package/src/render/blocks/catalog.ts +2 -0
- package/src/render/blocks/diff/myers.ts +221 -0
- package/src/render/blocks/diff/parse-unified.ts +101 -0
- package/src/render/blocks/highlight.ts +8 -11
- package/src/render/blocks/markdown.ts +28 -7
- package/src/render/blocks/render.ts +3 -0
- package/src/render/blocks/renderers/code.ts +1 -3
- package/src/render/blocks/renderers/compare-table.ts +3 -4
- package/src/render/blocks/renderers/diagram.ts +2 -5
- package/src/render/blocks/renderers/diff.ts +378 -0
- package/src/render/blocks/renderers/prose.ts +1 -2
- package/src/render/blocks/renderers/timeline.ts +2 -1
- package/src/render/blocks/themes/claret-dark.ts +1 -6
- package/src/render/blocks/themes/claret-light.ts +1 -6
- package/src/render/blocks/types.ts +13 -1
- package/src/render/blocks/validate-block.ts +19 -9
- package/src/render/theme.ts +131 -0
- package/src/render/validate.ts +53 -9
- package/src/server/lifecycle.ts +5 -1
- package/src/storage/index-gen.ts +2 -3
- package/src/tools/publish.ts +1 -3
- package/src/tools/styleguide.ts +3 -7
|
@@ -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(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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",
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Diff block renderer — split-view before/after code diff with bezier SVG connectors.
|
|
2
|
+
// src/render/blocks/renderers/diff.ts
|
|
3
|
+
|
|
4
|
+
import type { DiffBlock, BlockMeta } from "../types.ts";
|
|
5
|
+
import type { RenderCtx } from "../render.ts";
|
|
6
|
+
import { escapeHtml, escapeAttr } from "../escape.ts";
|
|
7
|
+
import { highlightCode } from "../highlight.ts";
|
|
8
|
+
import { parseUnifiedDiff } from "../diff/parse-unified.ts";
|
|
9
|
+
import type { DiffEntry, DiffLine } from "../diff/parse-unified.ts";
|
|
10
|
+
import { diffLines } from "../diff/myers.ts";
|
|
11
|
+
|
|
12
|
+
const LINE_H = 22; // px — must match .diff-line height in CSS
|
|
13
|
+
const SVG_W = 60; // px — connector column width
|
|
14
|
+
|
|
15
|
+
// ─── Change region ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
type RegionKind = "add" | "remove" | "change";
|
|
18
|
+
|
|
19
|
+
interface ChangeRegion {
|
|
20
|
+
kind: RegionKind;
|
|
21
|
+
leftStart: number; // 0-based index into leftLines
|
|
22
|
+
leftEnd: number;
|
|
23
|
+
rightStart: number; // 0-based index into rightLines
|
|
24
|
+
rightEnd: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── SVG path generation ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function svgPath(region: ChangeRegion): string {
|
|
30
|
+
const W = SVG_W;
|
|
31
|
+
const H = LINE_H;
|
|
32
|
+
|
|
33
|
+
const { kind, leftStart, leftEnd, rightStart, rightEnd } = region;
|
|
34
|
+
|
|
35
|
+
if (kind === "change") {
|
|
36
|
+
const y0l = leftStart * H;
|
|
37
|
+
const y1l = leftEnd * H;
|
|
38
|
+
const y0r = rightStart * H;
|
|
39
|
+
const y1r = rightEnd * H;
|
|
40
|
+
return (
|
|
41
|
+
`M 0 ${y0l} ` +
|
|
42
|
+
`C ${W / 2} ${y0l}, ${W / 2} ${y0r}, ${W} ${y0r} ` +
|
|
43
|
+
`L ${W} ${y1r} ` +
|
|
44
|
+
`C ${W / 2} ${y1r}, ${W / 2} ${y1l}, 0 ${y1l} ` +
|
|
45
|
+
`Z`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (kind === "add") {
|
|
50
|
+
// anchorY is on the left side (leftStart === leftEnd for pure add)
|
|
51
|
+
const anchorY = leftStart * H;
|
|
52
|
+
const y0r = rightStart * H;
|
|
53
|
+
const y1r = rightEnd * H;
|
|
54
|
+
return (
|
|
55
|
+
`M 0 ${anchorY} ` +
|
|
56
|
+
`C ${W / 2} ${anchorY}, ${W / 2} ${y0r}, ${W} ${y0r} ` +
|
|
57
|
+
`L ${W} ${y1r} ` +
|
|
58
|
+
`C ${W / 2} ${y1r}, ${W / 2} ${anchorY}, 0 ${anchorY} ` +
|
|
59
|
+
`Z`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// kind === "remove"
|
|
64
|
+
// anchorY is on the right side (rightStart === rightEnd for pure remove)
|
|
65
|
+
const anchorY = rightStart * H;
|
|
66
|
+
const y0l = leftStart * H;
|
|
67
|
+
const y1l = leftEnd * H;
|
|
68
|
+
return (
|
|
69
|
+
`M 0 ${y0l} ` +
|
|
70
|
+
`C ${W / 2} ${y0l}, ${W / 2} ${anchorY}, ${W} ${anchorY} ` +
|
|
71
|
+
`L ${W} ${anchorY} ` +
|
|
72
|
+
`C ${W / 2} ${anchorY}, ${W / 2} ${y1l}, 0 ${y1l} ` +
|
|
73
|
+
`Z`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Region detection ─────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function detectRegions(entries: DiffEntry[]): ChangeRegion[] {
|
|
80
|
+
const regions: ChangeRegion[] = [];
|
|
81
|
+
|
|
82
|
+
// Walk entries tracking left/right indices
|
|
83
|
+
let leftIdx = 0;
|
|
84
|
+
let rightIdx = 0;
|
|
85
|
+
|
|
86
|
+
// Region accumulation
|
|
87
|
+
let inRegion = false;
|
|
88
|
+
let regionLeftStart = 0;
|
|
89
|
+
let regionRightStart = 0;
|
|
90
|
+
let regionLeftEnd = 0;
|
|
91
|
+
let regionRightEnd = 0;
|
|
92
|
+
let hasRemoves = false;
|
|
93
|
+
let hasAdds = false;
|
|
94
|
+
|
|
95
|
+
function flushRegion() {
|
|
96
|
+
if (!inRegion) return;
|
|
97
|
+
const kind: RegionKind = hasAdds && hasRemoves ? "change" : hasAdds ? "add" : "remove";
|
|
98
|
+
regions.push({
|
|
99
|
+
kind,
|
|
100
|
+
leftStart: regionLeftStart,
|
|
101
|
+
leftEnd: regionLeftEnd,
|
|
102
|
+
rightStart: regionRightStart,
|
|
103
|
+
rightEnd: regionRightEnd,
|
|
104
|
+
});
|
|
105
|
+
inRegion = false;
|
|
106
|
+
hasAdds = false;
|
|
107
|
+
hasRemoves = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (entry.kind === "hunk-sep") {
|
|
112
|
+
flushRegion();
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { kind } = entry;
|
|
117
|
+
|
|
118
|
+
if (kind === "context") {
|
|
119
|
+
flushRegion();
|
|
120
|
+
leftIdx++;
|
|
121
|
+
rightIdx++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (kind === "remove") {
|
|
126
|
+
if (!inRegion) {
|
|
127
|
+
inRegion = true;
|
|
128
|
+
regionLeftStart = leftIdx;
|
|
129
|
+
regionRightStart = rightIdx;
|
|
130
|
+
hasRemoves = false;
|
|
131
|
+
hasAdds = false;
|
|
132
|
+
}
|
|
133
|
+
hasRemoves = true;
|
|
134
|
+
leftIdx++;
|
|
135
|
+
regionLeftEnd = leftIdx;
|
|
136
|
+
// For pure remove regions, rightEnd tracks the anchor
|
|
137
|
+
if (!hasAdds) {
|
|
138
|
+
regionRightEnd = rightIdx;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (kind === "add") {
|
|
144
|
+
if (!inRegion) {
|
|
145
|
+
inRegion = true;
|
|
146
|
+
regionLeftStart = leftIdx;
|
|
147
|
+
regionLeftEnd = leftIdx; // anchor for pure-add
|
|
148
|
+
regionRightStart = rightIdx;
|
|
149
|
+
hasRemoves = false;
|
|
150
|
+
hasAdds = false;
|
|
151
|
+
}
|
|
152
|
+
hasAdds = true;
|
|
153
|
+
rightIdx++;
|
|
154
|
+
regionRightEnd = rightIdx;
|
|
155
|
+
// For pure add, update leftEnd to track anchor
|
|
156
|
+
if (!hasRemoves) {
|
|
157
|
+
regionLeftEnd = leftIdx;
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
flushRegion();
|
|
164
|
+
return regions;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Highlight helpers ────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Highlight a multi-line text and return an array of highlighted line HTML strings.
|
|
171
|
+
* Each element corresponds to one source line.
|
|
172
|
+
*/
|
|
173
|
+
async function highlightLines(text: string, lang: string, ctx: RenderCtx): Promise<string[]> {
|
|
174
|
+
if (text === "") return [];
|
|
175
|
+
const highlighted = await highlightCode(text, lang, ctx.highlightTheme);
|
|
176
|
+
return highlighted.split("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Main renderer ────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
export async function renderDiff(block: DiffBlock, ctx: RenderCtx): Promise<string> {
|
|
182
|
+
const lang = block.lang ?? "text";
|
|
183
|
+
|
|
184
|
+
// ── 1. Resolve DiffEntry[] ─────────────────────────────────────────────────
|
|
185
|
+
let entries: DiffEntry[];
|
|
186
|
+
|
|
187
|
+
if (block.patch !== undefined) {
|
|
188
|
+
const parsed = parseUnifiedDiff(block.patch);
|
|
189
|
+
if (parsed === null) {
|
|
190
|
+
// Fallback: plaintext panel
|
|
191
|
+
const escaped = escapeHtml(block.patch);
|
|
192
|
+
const filename =
|
|
193
|
+
block.filename !== undefined
|
|
194
|
+
? `<span class="diff-filename">${escapeHtml(block.filename)}</span>`
|
|
195
|
+
: "";
|
|
196
|
+
const header = filename !== "" ? `<header class="diff-header">${filename}</header>\n` : "";
|
|
197
|
+
return (
|
|
198
|
+
`<figure class="diff-block fallback" data-lang="${escapeAttr(lang)}">\n` +
|
|
199
|
+
header +
|
|
200
|
+
` <pre><code>${escaped}</code></pre>\n` +
|
|
201
|
+
`</figure>`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
entries = parsed;
|
|
205
|
+
} else {
|
|
206
|
+
const before = block.before ?? "";
|
|
207
|
+
const after = block.after ?? "";
|
|
208
|
+
entries = diffLines(before, after);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── 2. Build left/right line lists & recompose text for shiki ─────────────
|
|
212
|
+
const leftEntries: DiffLine[] = [];
|
|
213
|
+
const rightEntries: DiffLine[] = [];
|
|
214
|
+
|
|
215
|
+
for (const e of entries) {
|
|
216
|
+
if (e.kind === "hunk-sep") continue;
|
|
217
|
+
if (e.kind === "context" || e.kind === "remove") {
|
|
218
|
+
leftEntries.push(e);
|
|
219
|
+
}
|
|
220
|
+
if (e.kind === "context" || e.kind === "add") {
|
|
221
|
+
rightEntries.push(e);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const beforeText = leftEntries.map((e) => e.text).join("\n");
|
|
226
|
+
const afterText = rightEntries.map((e) => e.text).join("\n");
|
|
227
|
+
|
|
228
|
+
const [leftHighlighted, rightHighlighted] = await Promise.all([
|
|
229
|
+
highlightLines(beforeText, lang, ctx),
|
|
230
|
+
highlightLines(afterText, lang, ctx),
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
// ── 3. Compute stats ───────────────────────────────────────────────────────
|
|
234
|
+
let addCount = 0;
|
|
235
|
+
let removeCount = 0;
|
|
236
|
+
for (const e of entries) {
|
|
237
|
+
if (e.kind === "add") addCount++;
|
|
238
|
+
if (e.kind === "remove") removeCount++;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 4. Detect change regions (for SVG connectors) ─────────────────────────
|
|
242
|
+
const regions = detectRegions(entries);
|
|
243
|
+
|
|
244
|
+
// ── 5. Compute SVG height ──────────────────────────────────────────────────
|
|
245
|
+
// Each side renders context+remove (left) or context+add (right) lines,
|
|
246
|
+
// plus one row per hunk-sep
|
|
247
|
+
let leftLineCount = 0;
|
|
248
|
+
let rightLineCount = 0;
|
|
249
|
+
for (const e of entries) {
|
|
250
|
+
if (e.kind === "hunk-sep") {
|
|
251
|
+
leftLineCount++;
|
|
252
|
+
rightLineCount++;
|
|
253
|
+
} else if (e.kind === "context") {
|
|
254
|
+
leftLineCount++;
|
|
255
|
+
rightLineCount++;
|
|
256
|
+
} else if (e.kind === "remove") {
|
|
257
|
+
leftLineCount++;
|
|
258
|
+
} else if (e.kind === "add") {
|
|
259
|
+
rightLineCount++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const svgH = Math.max(leftLineCount, rightLineCount) * LINE_H;
|
|
263
|
+
|
|
264
|
+
// ── 6. Render left side ────────────────────────────────────────────────────
|
|
265
|
+
let leftHighIdx = 0;
|
|
266
|
+
let rightHighIdx = 0;
|
|
267
|
+
const leftRows: string[] = [];
|
|
268
|
+
const rightRows: string[] = [];
|
|
269
|
+
|
|
270
|
+
for (const entry of entries) {
|
|
271
|
+
if (entry.kind === "hunk-sep") {
|
|
272
|
+
const sepLabel = `… @ ${entry.newStart}`;
|
|
273
|
+
const sepHtml = `<li class="diff-line hunk-sep"><span class="num"></span><span class="content">${escapeHtml(sepLabel)}</span></li>`;
|
|
274
|
+
leftRows.push(sepHtml);
|
|
275
|
+
rightRows.push(sepHtml);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const { kind } = entry;
|
|
280
|
+
|
|
281
|
+
if (kind === "context" || kind === "remove") {
|
|
282
|
+
const hl =
|
|
283
|
+
leftHighlighted[leftHighIdx] ?? `<span class="line">${escapeHtml(entry.text)}</span>`;
|
|
284
|
+
leftHighIdx++;
|
|
285
|
+
const lineNum = entry.beforeLineNum !== null ? String(entry.beforeLineNum) : "";
|
|
286
|
+
leftRows.push(
|
|
287
|
+
`<li class="diff-line ${kind}"><span class="num">${escapeHtml(lineNum)}</span><span class="content">${hl}</span></li>`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (kind === "context" || kind === "add") {
|
|
292
|
+
const hl =
|
|
293
|
+
rightHighlighted[rightHighIdx] ?? `<span class="line">${escapeHtml(entry.text)}</span>`;
|
|
294
|
+
rightHighIdx++;
|
|
295
|
+
const lineNum = entry.afterLineNum !== null ? String(entry.afterLineNum) : "";
|
|
296
|
+
rightRows.push(
|
|
297
|
+
`<li class="diff-line ${kind}"><span class="num">${escapeHtml(lineNum)}</span><span class="content">${hl}</span></li>`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── 7. Render SVG connector paths ─────────────────────────────────────────
|
|
303
|
+
const svgPaths = regions.map((region) => {
|
|
304
|
+
const d = svgPath(region);
|
|
305
|
+
return ` <path class="diff-conn ${region.kind}" d="${d}"/>`;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const svgEl =
|
|
309
|
+
` <div class="diff-connector" style="--lineH: ${LINE_H}px;">\n` +
|
|
310
|
+
` <svg viewBox="0 0 ${SVG_W} ${svgH}" preserveAspectRatio="none" aria-hidden="true" style="height: ${svgH}px">\n` +
|
|
311
|
+
svgPaths.join("\n") +
|
|
312
|
+
(svgPaths.length > 0 ? "\n" : "") +
|
|
313
|
+
` </svg>\n` +
|
|
314
|
+
` </div>`;
|
|
315
|
+
|
|
316
|
+
// ── 8. Header ──────────────────────────────────────────────────────────────
|
|
317
|
+
const filenameHtml =
|
|
318
|
+
block.filename !== undefined
|
|
319
|
+
? `<span class="diff-filename">${escapeHtml(block.filename)}</span>`
|
|
320
|
+
: `<span class="diff-filename"></span>`;
|
|
321
|
+
const statHtml = `<span class="diff-stat"><span class="add">+${addCount}</span> <span class="rem">-${removeCount}</span></span>`;
|
|
322
|
+
const headerHtml = `<header class="diff-header">${filenameHtml}${statHtml}</header>`;
|
|
323
|
+
|
|
324
|
+
// ── 9. Caption ─────────────────────────────────────────────────────────────
|
|
325
|
+
const captionHtml =
|
|
326
|
+
block.caption !== undefined ? `\n <figcaption>${escapeHtml(block.caption)}</figcaption>` : "";
|
|
327
|
+
|
|
328
|
+
// ── 10. Assemble ───────────────────────────────────────────────────────────
|
|
329
|
+
const leftOl =
|
|
330
|
+
` <ol class="diff-side before">\n` +
|
|
331
|
+
leftRows.map((r) => ` ${r}`).join("\n") +
|
|
332
|
+
(leftRows.length > 0 ? "\n" : "") +
|
|
333
|
+
` </ol>`;
|
|
334
|
+
|
|
335
|
+
const rightOl =
|
|
336
|
+
` <ol class="diff-side after">\n` +
|
|
337
|
+
rightRows.map((r) => ` ${r}`).join("\n") +
|
|
338
|
+
(rightRows.length > 0 ? "\n" : "") +
|
|
339
|
+
` </ol>`;
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
`<figure class="diff-block" data-lang="${escapeAttr(lang)}">\n` +
|
|
343
|
+
` ${headerHtml}\n` +
|
|
344
|
+
` <div class="diff-grid">\n` +
|
|
345
|
+
`${leftOl}\n` +
|
|
346
|
+
`${svgEl}\n` +
|
|
347
|
+
`${rightOl}\n` +
|
|
348
|
+
` </div>${captionHtml}\n` +
|
|
349
|
+
`</figure>`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const meta: BlockMeta = {
|
|
354
|
+
type: "diff",
|
|
355
|
+
description:
|
|
356
|
+
"Side-by-side before/after code diff with curved bezier connectors. Use for showing what changed or proposing a change. Provide either a unified diff `patch`, or both `before` and `after` strings.",
|
|
357
|
+
schema: {
|
|
358
|
+
type: "object",
|
|
359
|
+
properties: {
|
|
360
|
+
type: { const: "diff" },
|
|
361
|
+
patch: { type: "string" },
|
|
362
|
+
before: { type: "string" },
|
|
363
|
+
after: { type: "string" },
|
|
364
|
+
lang: { type: "string" },
|
|
365
|
+
filename: { type: "string" },
|
|
366
|
+
caption: { type: "string" },
|
|
367
|
+
},
|
|
368
|
+
required: ["type"],
|
|
369
|
+
},
|
|
370
|
+
example: {
|
|
371
|
+
type: "diff",
|
|
372
|
+
filename: "src/auth.ts",
|
|
373
|
+
lang: "typescript",
|
|
374
|
+
before: "function login(user, pass) {\n return db.find(user, pass);\n}",
|
|
375
|
+
after:
|
|
376
|
+
"async function login(user, pass) {\n const u = await db.find(user);\n if (!u) return null;\n return verify(u, pass) ? u : null;\n}",
|
|
377
|
+
},
|
|
378
|
+
};
|
|
@@ -23,7 +23,6 @@ export const meta: BlockMeta = {
|
|
|
23
23
|
},
|
|
24
24
|
example: {
|
|
25
25
|
type: "prose",
|
|
26
|
-
markdown:
|
|
27
|
-
"This is a paragraph with **bold** and *italic* text.\n\n- Item one\n- Item two",
|
|
26
|
+
markdown: "This is a paragraph with **bold** and *italic* text.\n\n- Item one\n- Item two",
|
|
28
27
|
},
|
|
29
28
|
};
|
|
@@ -27,7 +27,8 @@ export function renderTimeline(block: TimelineBlock, _ctx: RenderCtx): string {
|
|
|
27
27
|
|
|
28
28
|
export const meta: BlockMeta = {
|
|
29
29
|
type: "timeline",
|
|
30
|
-
description:
|
|
30
|
+
description:
|
|
31
|
+
"Milestone list with dot connectors. Each item has a label, text, and optional date.",
|
|
31
32
|
schema: {
|
|
32
33
|
type: "object",
|
|
33
34
|
properties: {
|
|
@@ -78,12 +78,7 @@ export const claretDark: ThemeRegistration = {
|
|
|
78
78
|
// Type — #8995A8
|
|
79
79
|
{
|
|
80
80
|
name: "Type",
|
|
81
|
-
scope: [
|
|
82
|
-
"entity.name.type",
|
|
83
|
-
"entity.name.class",
|
|
84
|
-
"support.type",
|
|
85
|
-
"support.class",
|
|
86
|
-
],
|
|
81
|
+
scope: ["entity.name.type", "entity.name.class", "support.type", "support.class"],
|
|
87
82
|
settings: { foreground: "#8995A8" },
|
|
88
83
|
},
|
|
89
84
|
// Variable — #DDD3C7
|
|
@@ -99,12 +99,7 @@ export const claretLight: ThemeRegistration = {
|
|
|
99
99
|
// Type — #0E3088
|
|
100
100
|
{
|
|
101
101
|
name: "Type",
|
|
102
|
-
scope: [
|
|
103
|
-
"entity.name.type",
|
|
104
|
-
"entity.name.class",
|
|
105
|
-
"support.type",
|
|
106
|
-
"support.class",
|
|
107
|
-
],
|
|
102
|
+
scope: ["entity.name.type", "entity.name.class", "support.type", "support.class"],
|
|
108
103
|
settings: { foreground: "#0E3088" },
|
|
109
104
|
},
|
|
110
105
|
// Variable — #2A1F1A
|
|
@@ -16,7 +16,8 @@ export type Block =
|
|
|
16
16
|
| PillRowBlock
|
|
17
17
|
| DividerBlock
|
|
18
18
|
| DiagramBlock
|
|
19
|
-
| RawHtmlBlock
|
|
19
|
+
| RawHtmlBlock
|
|
20
|
+
| DiffBlock;
|
|
20
21
|
|
|
21
22
|
export type HeroBlock = {
|
|
22
23
|
type: "hero";
|
|
@@ -116,6 +117,17 @@ export type RawHtmlBlock = {
|
|
|
116
117
|
purpose?: string; // brief reason; surfaced in critique findings
|
|
117
118
|
};
|
|
118
119
|
|
|
120
|
+
export type DiffBlock = {
|
|
121
|
+
type: "diff";
|
|
122
|
+
// Provide exactly one of these arms:
|
|
123
|
+
patch?: string; // unified diff format
|
|
124
|
+
before?: string; // OR
|
|
125
|
+
after?: string; // (paired with before)
|
|
126
|
+
lang?: string; // for per-line shiki highlight; default "text"
|
|
127
|
+
filename?: string; // optional, shown in header strip
|
|
128
|
+
caption?: string; // optional, shown below the diff
|
|
129
|
+
};
|
|
130
|
+
|
|
119
131
|
// ─── BlockMeta ───────────────────────────────────────────────────────────────
|
|
120
132
|
|
|
121
133
|
export type BlockMeta = {
|
|
@@ -23,11 +23,7 @@ function levenshtein(a: string, b: string): number {
|
|
|
23
23
|
curr[0] = i;
|
|
24
24
|
for (let j = 1; j <= bLen; j++) {
|
|
25
25
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
26
|
-
curr[j] = Math.min(
|
|
27
|
-
(prev[j] ?? 0) + 1,
|
|
28
|
-
(curr[j - 1] ?? 0) + 1,
|
|
29
|
-
(prev[j - 1] ?? 0) + cost,
|
|
30
|
-
);
|
|
26
|
+
curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
|
|
31
27
|
}
|
|
32
28
|
[prev, curr] = [curr, prev];
|
|
33
29
|
}
|
|
@@ -91,11 +87,19 @@ function isSchemaNode(v: unknown): v is SchemaNode {
|
|
|
91
87
|
* Validate a single value against a JSON Schema fragment node.
|
|
92
88
|
* Appends any findings to `errors`. `path` is the dotted path for error messages.
|
|
93
89
|
*/
|
|
94
|
-
function validateNode(
|
|
90
|
+
function validateNode(
|
|
91
|
+
value: unknown,
|
|
92
|
+
schema: SchemaNode,
|
|
93
|
+
path: string,
|
|
94
|
+
errors: BlockFieldError[],
|
|
95
|
+
): void {
|
|
95
96
|
// const node
|
|
96
97
|
if ("const" in schema) {
|
|
97
98
|
if (value !== schema.const) {
|
|
98
|
-
errors.push({
|
|
99
|
+
errors.push({
|
|
100
|
+
path,
|
|
101
|
+
message: `expected ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`,
|
|
102
|
+
});
|
|
99
103
|
}
|
|
100
104
|
return;
|
|
101
105
|
}
|
|
@@ -143,7 +147,10 @@ function validateNode(value: unknown, schema: SchemaNode, path: string, errors:
|
|
|
143
147
|
}
|
|
144
148
|
case "object": {
|
|
145
149
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
146
|
-
errors.push({
|
|
150
|
+
errors.push({
|
|
151
|
+
path,
|
|
152
|
+
message: `expected object, got ${Array.isArray(value) ? "array" : typeof value}`,
|
|
153
|
+
});
|
|
147
154
|
return;
|
|
148
155
|
}
|
|
149
156
|
const obj = value as Record<string, unknown>;
|
|
@@ -171,7 +178,10 @@ function validateNode(value: unknown, schema: SchemaNode, path: string, errors:
|
|
|
171
178
|
// Unknown field — suggest closest known
|
|
172
179
|
const suggestion = didYouMean(key, knownKeys);
|
|
173
180
|
const suggestionMsg = suggestion !== null ? `; did you mean "${suggestion}"?` : "";
|
|
174
|
-
errors.push({
|
|
181
|
+
errors.push({
|
|
182
|
+
path: `${path}.${key}`,
|
|
183
|
+
message: `unknown field "${key}"${suggestionMsg}`,
|
|
184
|
+
});
|
|
175
185
|
} else if (isSchemaNode(propSchema)) {
|
|
176
186
|
validateNode(val, propSchema, `${path}.${key}`, errors);
|
|
177
187
|
}
|