@cfbender/cesium 0.5.0 → 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.
@@ -0,0 +1,222 @@
1
+ // Claret-light shiki theme — derived from claret.nvim light palette.
2
+ // src/render/blocks/themes/claret-light.ts
3
+ //
4
+ // Source of truth: /claret.nvim/lua/claret/palette.lua (light section)
5
+ // Scope-to-role mapping mirrors ClaretDark.tmTheme exactly; colors
6
+ // are substituted from the light palette using the same semantic roles.
7
+ //
8
+ // Light palette (palette.lua):
9
+ // bg #F5E6E2 editor background
10
+ // bg_mute #DDD0CC selection / line highlight
11
+ // text #2A1F1A default fg
12
+ // text_4 #928578 comment / gutter fg
13
+ // rose_1 #B80842 keyword / heading / statement
14
+ // rose_2 #920820 property / data keys
15
+ // gold_1 #946000 function / number / constant / decorator
16
+ // sage_1 #1B5500 string
17
+ // slate_1 #0E3088 type / class / tag / escape / link
18
+ // slate_2 #0A2575 tag attribute
19
+ // terra_1 #D42010 invalid / diff deleted
20
+ //
21
+ // Role mapping (matching dark tmTheme):
22
+ // Comment → text_4 #928578 italic
23
+ // Keyword → rose_1 #B80842
24
+ // Function → gold_1 #946000
25
+ // String → sage_1 #1B5500
26
+ // Number → gold_1 #946000
27
+ // Constant → gold_1 #946000
28
+ // Type → slate_1 #0E3088
29
+ // Variable → text #2A1F1A
30
+ // Parameter → text #2A1F1A italic
31
+ // Property → rose_2 #920820
32
+ // Keys → rose_2 #920820
33
+ // Operator → text_4 #928578 (text_3 equivalent; nearest warm muted in light palette)
34
+ // Punctuation → text_4 #928578
35
+ // Decorator → gold_1 #946000 italic
36
+ // Tag → slate_1 #0E3088
37
+ // Tag Attribute → slate_2 #0A2575
38
+ // Invalid → terra_1 #D42010
39
+ // Escape → slate_1 #0E3088
40
+ // Markup Heading→ rose_1 #B80842 bold
41
+ // Markup Bold → (no fg) bold
42
+ // Markup Italic → (no fg) italic
43
+ // Markup Link → slate_1 #0E3088
44
+ // Diff Added → sage_1 #1B5500
45
+ // Diff Deleted → terra_1 #D42010
46
+ // Diff Changed → gold_1 #946000
47
+
48
+ import type { ThemeRegistration } from "shiki";
49
+
50
+ export const claretLight: ThemeRegistration = {
51
+ name: "claret-light",
52
+ type: "light",
53
+ fg: "#2A1F1A",
54
+ bg: "#F5E6E2",
55
+ colors: {
56
+ "editor.foreground": "#2A1F1A",
57
+ "editor.background": "#F5E6E2",
58
+ "editor.selectionBackground": "#DDD0CC",
59
+ "editor.lineHighlightBackground": "#DDD0CC",
60
+ "editorLineNumber.foreground": "#928578",
61
+ },
62
+ tokenColors: [
63
+ // Comment — #928578 italic
64
+ {
65
+ name: "Comment",
66
+ scope: ["comment", "punctuation.definition.comment"],
67
+ settings: { foreground: "#928578", fontStyle: "italic" },
68
+ },
69
+ // Keyword — #B80842
70
+ {
71
+ name: "Keyword",
72
+ scope: ["keyword", "storage.type", "storage.modifier"],
73
+ settings: { foreground: "#B80842" },
74
+ },
75
+ // Function — #946000
76
+ {
77
+ name: "Function",
78
+ scope: ["entity.name.function", "support.function"],
79
+ settings: { foreground: "#946000" },
80
+ },
81
+ // String — #1B5500
82
+ {
83
+ name: "String",
84
+ scope: ["string", "punctuation.definition.string"],
85
+ settings: { foreground: "#1B5500" },
86
+ },
87
+ // Number — #946000
88
+ {
89
+ name: "Number",
90
+ scope: ["constant.numeric"],
91
+ settings: { foreground: "#946000" },
92
+ },
93
+ // Constant — #946000
94
+ {
95
+ name: "Constant",
96
+ scope: ["constant", "constant.language", "variable.language"],
97
+ settings: { foreground: "#946000" },
98
+ },
99
+ // Type — #0E3088
100
+ {
101
+ name: "Type",
102
+ scope: ["entity.name.type", "entity.name.class", "support.type", "support.class"],
103
+ settings: { foreground: "#0E3088" },
104
+ },
105
+ // Variable — #2A1F1A
106
+ {
107
+ name: "Variable",
108
+ scope: ["variable", "variable.parameter"],
109
+ settings: { foreground: "#2A1F1A" },
110
+ },
111
+ // Parameter — #2A1F1A italic (overrides Variable for parameters)
112
+ {
113
+ name: "Parameter",
114
+ scope: ["variable.parameter"],
115
+ settings: { foreground: "#2A1F1A", fontStyle: "italic" },
116
+ },
117
+ // Property — #920820
118
+ {
119
+ name: "Property",
120
+ scope: ["variable.other.property", "variable.other.member"],
121
+ settings: { foreground: "#920820" },
122
+ },
123
+ // JSON/YAML/TOML Keys — #920820
124
+ {
125
+ name: "JSON/YAML/TOML Keys",
126
+ scope: [
127
+ "meta.mapping.key string",
128
+ "support.type.property-name.json",
129
+ "punctuation.support.type.property-name.json",
130
+ "support.type.property-name.toml",
131
+ "punctuation.support.type.property-name.toml",
132
+ "entity.name.tag.yaml",
133
+ "support.type.property-name.yaml",
134
+ ],
135
+ settings: { foreground: "#920820" },
136
+ },
137
+ // Operator — #928578
138
+ {
139
+ name: "Operator",
140
+ scope: ["keyword.operator"],
141
+ settings: { foreground: "#928578" },
142
+ },
143
+ // Punctuation — #928578
144
+ {
145
+ name: "Punctuation",
146
+ scope: ["punctuation"],
147
+ settings: { foreground: "#928578" },
148
+ },
149
+ // Decorator — #946000 italic
150
+ {
151
+ name: "Decorator",
152
+ scope: ["meta.decorator", "punctuation.decorator"],
153
+ settings: { foreground: "#946000", fontStyle: "italic" },
154
+ },
155
+ // Tag — #0E3088
156
+ {
157
+ name: "Tag",
158
+ scope: ["entity.name.tag"],
159
+ settings: { foreground: "#0E3088" },
160
+ },
161
+ // Tag Attribute — #0A2575
162
+ {
163
+ name: "Tag Attribute",
164
+ scope: ["entity.other.attribute-name"],
165
+ settings: { foreground: "#0A2575" },
166
+ },
167
+ // Invalid — #D42010
168
+ {
169
+ name: "Invalid",
170
+ scope: ["invalid", "invalid.illegal"],
171
+ settings: { foreground: "#D42010" },
172
+ },
173
+ // Escape — #0E3088
174
+ {
175
+ name: "Escape",
176
+ scope: ["constant.character.escape"],
177
+ settings: { foreground: "#0E3088" },
178
+ },
179
+ // Markup Heading — #B80842 bold
180
+ {
181
+ name: "Markup Heading",
182
+ scope: ["markup.heading"],
183
+ settings: { foreground: "#B80842", fontStyle: "bold" },
184
+ },
185
+ // Markup Bold — bold (no color override)
186
+ {
187
+ name: "Markup Bold",
188
+ scope: ["markup.bold"],
189
+ settings: { fontStyle: "bold" },
190
+ },
191
+ // Markup Italic — italic (no color override)
192
+ {
193
+ name: "Markup Italic",
194
+ scope: ["markup.italic"],
195
+ settings: { fontStyle: "italic" },
196
+ },
197
+ // Markup Link — #0E3088
198
+ {
199
+ name: "Markup Link",
200
+ scope: ["markup.underline.link", "string.other.link"],
201
+ settings: { foreground: "#0E3088" },
202
+ },
203
+ // Diff Added — #1B5500
204
+ {
205
+ name: "Diff Added",
206
+ scope: ["markup.inserted"],
207
+ settings: { foreground: "#1B5500" },
208
+ },
209
+ // Diff Deleted — #D42010
210
+ {
211
+ name: "Diff Deleted",
212
+ scope: ["markup.deleted"],
213
+ settings: { foreground: "#D42010" },
214
+ },
215
+ // Diff Changed — #946000
216
+ {
217
+ name: "Diff Changed",
218
+ scope: ["markup.changed"],
219
+ settings: { foreground: "#946000" },
220
+ },
221
+ ],
222
+ };
@@ -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(value: unknown, schema: SchemaNode, path: string, errors: BlockFieldError[]): void {
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({ path, message: `expected ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}` });
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({ path, message: `expected object, got ${Array.isArray(value) ? "array" : typeof value}` });
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({ path: `${path}.${key}`, message: `unknown field "${key}"${suggestionMsg}` });
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
  }
@@ -13,6 +13,9 @@ export interface ThemePalette {
13
13
  olive: string;
14
14
  codeBg: string;
15
15
  codeFg: string;
16
+ diffAdd: string; // line-tint and connector color for additions
17
+ diffRemove: string; // line-tint and connector color for deletions
18
+ diffChange: string; // connector color for replacements
16
19
  }
17
20
 
18
21
  export interface ThemeFonts {
@@ -56,6 +59,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
56
59
  olive: "#8FA86E",
57
60
  codeBg: "#2B1F22",
58
61
  codeFg: "#DDD3C7",
62
+ diffAdd: "#6D9E60",
63
+ diffRemove: "#C75B5B",
64
+ diffChange: "#D4A85A",
59
65
  },
60
66
  // claret-light: deep-rose-on-warm-cream — derived from claret.nvim light palette.
61
67
  // (this is the old "claret" palette, now renamed)
@@ -72,6 +78,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
72
78
  olive: "#5A6B40",
73
79
  codeBg: "#180810",
74
80
  codeFg: "#DDD3C7",
81
+ diffAdd: "#5A6B40",
82
+ diffRemove: "#9E3838",
83
+ diffChange: "#B07A2A",
75
84
  },
76
85
  // claret: alias for claret-dark (backward compat)
77
86
  claret: {
@@ -87,6 +96,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
87
96
  olive: "#8FA86E",
88
97
  codeBg: "#2B1F22",
89
98
  codeFg: "#DDD3C7",
99
+ diffAdd: "#6D9E60",
100
+ diffRemove: "#C75B5B",
101
+ diffChange: "#D4A85A",
90
102
  },
91
103
  // Warm: ivory/clay/oat — the html-effectiveness reference palette.
92
104
  warm: {
@@ -102,6 +114,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
102
114
  olive: "#788C5D",
103
115
  codeBg: "#141413",
104
116
  codeFg: "#E8E6DE",
117
+ diffAdd: "#788C5D",
118
+ diffRemove: "#C0392B",
119
+ diffChange: "#D97757",
105
120
  },
106
121
  // Cool: desaturated blue-grey — technical, trustworthy.
107
122
  cool: {
@@ -117,6 +132,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
117
132
  olive: "#4E8A6A",
118
133
  codeBg: "#1B2333",
119
134
  codeFg: "#D8E0ED",
135
+ diffAdd: "#4E8A6A",
136
+ diffRemove: "#B6443A",
137
+ diffChange: "#3A7BB8",
120
138
  },
121
139
  // Mono: black/white/grey — editorial, high-contrast.
122
140
  mono: {
@@ -132,6 +150,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
132
150
  olive: "#5A7A5A",
133
151
  codeBg: "#111111",
134
152
  codeFg: "#EBEBEB",
153
+ diffAdd: "#5A7A5A",
154
+ diffRemove: "#A03A2B",
155
+ diffChange: "#666666",
135
156
  },
136
157
  // Paper: sepia/cream — soft, book-like, warm and aged.
137
158
  paper: {
@@ -147,6 +168,9 @@ export const THEME_PRESETS: Readonly<Record<ThemePresetName, ThemePalette>> = {
147
168
  olive: "#607848",
148
169
  codeBg: "#2A2218",
149
170
  codeFg: "#E8DEC8",
171
+ diffAdd: "#607848",
172
+ diffRemove: "#A0392B",
173
+ diffChange: "#B05A2A",
150
174
  },
151
175
  };
152
176
 
@@ -198,6 +222,9 @@ export function themeToCssVars(theme: ThemeTokens): string {
198
222
  --olive: ${colors.olive};
199
223
  --code-bg: ${colors.codeBg};
200
224
  --code-fg: ${colors.codeFg};
225
+ --diff-add: ${colors.diffAdd};
226
+ --diff-remove: ${colors.diffRemove};
227
+ --diff-change: ${colors.diffChange};
201
228
  --serif: ${fonts.serif};
202
229
  --sans: ${fonts.sans};
203
230
  --mono: ${fonts.mono};
@@ -936,6 +963,110 @@ textarea.cs-text { font-family: var(--mono); }
936
963
  font-weight: 600;
937
964
  z-index: 10;
938
965
  }
966
+
967
+ /* diff block */
968
+ .diff-block {
969
+ margin: var(--space-6, 1.5rem) 0;
970
+ border: 1.5px solid var(--rule);
971
+ border-radius: 12px;
972
+ overflow: hidden;
973
+ background: var(--code-bg);
974
+ font-family: var(--mono);
975
+ font-size: 13px;
976
+ color: var(--code-fg);
977
+ }
978
+ .diff-block.fallback pre {
979
+ margin: 0;
980
+ padding: 12px 14px;
981
+ white-space: pre;
982
+ overflow-x: auto;
983
+ }
984
+ .diff-header {
985
+ display: flex;
986
+ justify-content: space-between;
987
+ align-items: center;
988
+ padding: 8px 14px;
989
+ border-bottom: 1px solid color-mix(in oklab, var(--rule), transparent 40%);
990
+ background: color-mix(in oklab, var(--code-bg), var(--code-fg) 5%);
991
+ font-size: 12px;
992
+ color: color-mix(in oklab, var(--code-fg), transparent 30%);
993
+ }
994
+ .diff-filename { font-weight: 500; }
995
+ .diff-stat { font-variant-numeric: tabular-nums; display: inline-flex; gap: 8px; }
996
+ .diff-stat .add { color: var(--diff-add); }
997
+ .diff-stat .rem { color: var(--diff-remove); }
998
+
999
+ .diff-grid {
1000
+ display: grid;
1001
+ grid-template-columns: 1fr 60px 1fr;
1002
+ align-items: start;
1003
+ }
1004
+ .diff-side {
1005
+ list-style: none;
1006
+ margin: 0;
1007
+ padding: 8px 0;
1008
+ overflow-x: auto;
1009
+ min-width: 0;
1010
+ }
1011
+ .diff-line {
1012
+ display: grid;
1013
+ grid-template-columns: 3.25em 1fr;
1014
+ gap: 0;
1015
+ height: 22px;
1016
+ line-height: 22px;
1017
+ white-space: pre;
1018
+ }
1019
+ .diff-line .num {
1020
+ text-align: right;
1021
+ padding-right: 12px;
1022
+ color: color-mix(in oklab, var(--code-fg), transparent 60%);
1023
+ user-select: none;
1024
+ font-variant-numeric: tabular-nums;
1025
+ }
1026
+ .diff-line .content {
1027
+ padding-right: 14px;
1028
+ overflow: hidden;
1029
+ text-overflow: ellipsis;
1030
+ }
1031
+ .diff-line.add { background: color-mix(in oklab, transparent, var(--diff-add) 14%); }
1032
+ .diff-line.remove { background: color-mix(in oklab, transparent, var(--diff-remove) 14%); }
1033
+ .diff-line.hunk-sep {
1034
+ background: color-mix(in oklab, var(--code-bg), var(--code-fg) 8%);
1035
+ color: color-mix(in oklab, var(--code-fg), transparent 50%);
1036
+ font-style: italic;
1037
+ font-size: 11px;
1038
+ }
1039
+
1040
+ .diff-connector {
1041
+ position: relative;
1042
+ align-self: stretch;
1043
+ padding: 8px 0;
1044
+ background: color-mix(in oklab, var(--code-bg), var(--code-fg) 2%);
1045
+ border-left: 1px solid color-mix(in oklab, var(--rule), transparent 60%);
1046
+ border-right: 1px solid color-mix(in oklab, var(--rule), transparent 60%);
1047
+ }
1048
+ .diff-connector svg {
1049
+ display: block;
1050
+ width: 100%;
1051
+ }
1052
+ .diff-conn { stroke-width: 1; }
1053
+ .diff-conn.add { fill: var(--diff-add); stroke: var(--diff-add); fill-opacity: 0.22; }
1054
+ .diff-conn.remove { fill: var(--diff-remove); stroke: var(--diff-remove); fill-opacity: 0.22; }
1055
+ .diff-conn.change { fill: var(--diff-change); stroke: var(--diff-change); fill-opacity: 0.18; }
1056
+
1057
+ .diff-block figcaption {
1058
+ padding: 8px 14px;
1059
+ border-top: 1px solid color-mix(in oklab, var(--rule), transparent 40%);
1060
+ font-size: 12px;
1061
+ font-family: var(--sans);
1062
+ color: color-mix(in oklab, var(--code-fg), transparent 30%);
1063
+ }
1064
+
1065
+ @media (max-width: 720px) {
1066
+ .diff-grid { grid-template-columns: 1fr; }
1067
+ .diff-connector { display: none; }
1068
+ .diff-side.before { border-bottom: 1px solid color-mix(in oklab, var(--rule), transparent 60%); }
1069
+ }
939
1070
  `;
940
1071
  }
941
1072
 
@@ -439,7 +439,9 @@ function isPublishKind(val: unknown): val is PublishKind {
439
439
  // ─── Block validation ─────────────────────────────────────────────────────────
440
440
 
441
441
  type BlockValidationError = { path: string; message: string };
442
- type BlockValidationResult = { ok: true; blocks: Block[] } | { ok: false; errors: BlockValidationError[] };
442
+ type BlockValidationResult =
443
+ | { ok: true; blocks: Block[] }
444
+ | { ok: false; errors: BlockValidationError[] };
443
445
 
444
446
  function validateBlock(raw: unknown, path: string, depth: number): BlockValidationError[] {
445
447
  const errors: BlockValidationError[] = [];
@@ -484,7 +486,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
484
486
  errors.push({ path, message: "section block requires children array" });
485
487
  } else {
486
488
  if (depth > 3) {
487
- errors.push({ path, message: `section nesting depth exceeds maximum of 3 (current depth: ${depth})` });
489
+ errors.push({
490
+ path,
491
+ message: `section nesting depth exceeds maximum of 3 (current depth: ${depth})`,
492
+ });
488
493
  } else {
489
494
  const children = b["children"] as unknown[];
490
495
  for (let i = 0; i < children.length; i++) {
@@ -518,7 +523,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
518
523
  }
519
524
  case "code": {
520
525
  if (typeof b["lang"] !== "string" || (b["lang"] as string).trim() === "") {
521
- errors.push({ path, message: 'code block requires a non-empty lang (use "text" if unknown)' });
526
+ errors.push({
527
+ path,
528
+ message: 'code block requires a non-empty lang (use "text" if unknown)',
529
+ });
522
530
  }
523
531
  if (typeof b["code"] !== "string") {
524
532
  errors.push({ path, message: "code block requires code field" });
@@ -580,7 +588,10 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
580
588
  const hasSvg = typeof b["svg"] === "string";
581
589
  const hasHtml = typeof b["html"] === "string";
582
590
  if (hasSvg && hasHtml) {
583
- errors.push({ path, message: "diagram block must have exactly one of svg or html, not both" });
591
+ errors.push({
592
+ path,
593
+ message: "diagram block must have exactly one of svg or html, not both",
594
+ });
584
595
  } else if (!hasSvg && !hasHtml) {
585
596
  errors.push({ path, message: "diagram block requires exactly one of svg or html" });
586
597
  }
@@ -592,6 +603,35 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
592
603
  }
593
604
  break;
594
605
  }
606
+ case "diff": {
607
+ const hasPatch = "patch" in b && b["patch"] !== undefined;
608
+ const hasBefore = "before" in b && b["before"] !== undefined;
609
+ const hasAfter = "after" in b && b["after"] !== undefined;
610
+
611
+ if (hasPatch && (hasBefore || hasAfter)) {
612
+ errors.push({
613
+ path,
614
+ message: "provide exactly one of patch or before/after, not both",
615
+ });
616
+ } else if (!hasPatch && !hasBefore && !hasAfter) {
617
+ errors.push({
618
+ path,
619
+ message: "diff block requires either patch or before+after",
620
+ });
621
+ } else if (hasPatch) {
622
+ if (typeof b["patch"] !== "string" || (b["patch"] as string).trim() === "") {
623
+ errors.push({ path, message: "diff block patch must be a non-empty string" });
624
+ }
625
+ } else {
626
+ // before/after arm
627
+ if (hasBefore && !hasAfter) {
628
+ errors.push({ path, message: "diff block before/after arm requires both fields" });
629
+ } else if (!hasBefore && hasAfter) {
630
+ errors.push({ path, message: "diff block before/after arm requires both fields" });
631
+ }
632
+ }
633
+ break;
634
+ }
595
635
  }
596
636
 
597
637
  // Deep field validation against catalog schema — catches unknown fields (with "did you mean"
@@ -611,7 +651,10 @@ function validateBlocksArray(raw: unknown): BlockValidationResult {
611
651
  }
612
652
 
613
653
  if (raw.length > 1000) {
614
- return { ok: false, errors: [{ path: "blocks", message: "blocks array exceeds maximum of 1000 blocks" }] };
654
+ return {
655
+ ok: false,
656
+ errors: [{ path: "blocks", message: "blocks array exceeds maximum of 1000 blocks" }],
657
+ };
615
658
  }
616
659
 
617
660
  const allErrors: BlockValidationError[] = [];
@@ -627,7 +670,10 @@ function validateBlocksArray(raw: unknown): BlockValidationResult {
627
670
  if (blockType === "hero") {
628
671
  heroCount++;
629
672
  if (i !== 0) {
630
- allErrors.push({ path: `blocks[${i}]`, message: "hero block must be the first block if present" });
673
+ allErrors.push({
674
+ path: `blocks[${i}]`,
675
+ message: "hero block must be the first block if present",
676
+ });
631
677
  }
632
678
  }
633
679
  if (blockType === "tldr") {
@@ -740,9 +786,7 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
740
786
  // blocks branch
741
787
  const blocksResult = validateBlocksArray(raw["blocks"]);
742
788
  if (!blocksResult.ok) {
743
- const errorMessages = blocksResult.errors
744
- .map((e) => `${e.path}: ${e.message}`)
745
- .join("; ");
789
+ const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
746
790
  return { ok: false, error: `blocks validation failed — ${errorMessages}` };
747
791
  }
748
792
  return {