@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,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
+ }
@@ -0,0 +1,185 @@
1
+ // Server-side syntax highlighting via shiki.
2
+ // src/render/blocks/highlight.ts
3
+ //
4
+ // Lazy-initializes a shared highlighter on first call.
5
+ // Returns styled <span> tokens only — no <pre> wrapper.
6
+ // The caller (code renderer) is responsible for the <pre><code> panel chrome.
7
+
8
+ import type { ThemedToken, BundledLanguage } from "shiki";
9
+ import { escapeHtml } from "./escape.ts";
10
+ import { claretDark } from "./themes/claret-dark.ts";
11
+ import { claretLight } from "./themes/claret-light.ts";
12
+ import { THEME_PRESETS, isThemePresetName } from "../../render/theme.ts";
13
+
14
+ // ─── Highlight theme type ─────────────────────────────────────────────────────
15
+
16
+ export type HighlightTheme = "claret-dark" | "claret-light" | "vitesse-dark" | "vitesse-light";
17
+
18
+ // ─── Supported languages ─────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * The curated language list loaded into the highlighter.
22
+ * ~25 languages covering 95 %+ of real use.
23
+ */
24
+ export const SUPPORTED_LANGUAGES: readonly string[] = [
25
+ "typescript",
26
+ "ts",
27
+ "tsx",
28
+ "javascript",
29
+ "js",
30
+ "jsx",
31
+ "json",
32
+ "html",
33
+ "css",
34
+ "markdown",
35
+ "md",
36
+ "shellscript",
37
+ "sh",
38
+ "bash",
39
+ "shell",
40
+ "python",
41
+ "py",
42
+ "rust",
43
+ "go",
44
+ "ruby",
45
+ "rb",
46
+ "yaml",
47
+ "yml",
48
+ "sql",
49
+ "toml",
50
+ "dockerfile",
51
+ "diff",
52
+ ];
53
+
54
+ // ─── Theme resolution ─────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Map a cesium theme preset name to the appropriate shiki highlight theme.
58
+ *
59
+ * - "claret" / "claret-dark" → "claret-dark" (custom)
60
+ * - "claret-light" → "claret-light" (custom)
61
+ * - other named preset → vitesse-dark if codeBg is dark, else vitesse-light
62
+ * - unknown / undefined → "claret-dark" (matches framework default in themeFromPreset)
63
+ */
64
+ export function resolveHighlightTheme(cesiumThemeName: string | undefined): HighlightTheme {
65
+ if (cesiumThemeName === "claret" || cesiumThemeName === "claret-dark") {
66
+ return "claret-dark";
67
+ }
68
+ if (cesiumThemeName === "claret-light") {
69
+ return "claret-light";
70
+ }
71
+ if (cesiumThemeName !== undefined && isThemePresetName(cesiumThemeName)) {
72
+ const palette = THEME_PRESETS[cesiumThemeName];
73
+ // Use the code panel background color to choose the shiki theme.
74
+ // All current non-claret presets have a dark codeBg, but check anyway.
75
+ return isHexDark(palette.codeBg) ? "vitesse-dark" : "vitesse-light";
76
+ }
77
+ // undefined / unknown — match the framework theme default (claret-dark).
78
+ return "claret-dark";
79
+ }
80
+
81
+ /**
82
+ * Returns true if the hex color's perceived luminance is dark (< 0.5).
83
+ * Accepts 3- or 6-digit hex with or without leading '#'.
84
+ */
85
+ function isHexDark(hex: string): boolean {
86
+ const clean = hex.replace("#", "");
87
+ const full =
88
+ clean.length === 3
89
+ ? clean
90
+ .split("")
91
+ .map((c) => c + c)
92
+ .join("")
93
+ : clean;
94
+ const r = parseInt(full.slice(0, 2), 16);
95
+ const g = parseInt(full.slice(2, 4), 16);
96
+ const b = parseInt(full.slice(4, 6), 16);
97
+ // Simple average luminance threshold
98
+ const avg = (r + g + b) / 3;
99
+ return avg < 128;
100
+ }
101
+
102
+ // ─── Highlighter singleton ────────────────────────────────────────────────────
103
+
104
+ /** Promise cache — concurrent first-calls share one init. */
105
+ let highlighterPromise: Promise<import("shiki").Highlighter> | null = null;
106
+
107
+ async function getHighlighter(): Promise<import("shiki").Highlighter> {
108
+ if (highlighterPromise === null) {
109
+ const { createHighlighter } = await import("shiki");
110
+ highlighterPromise = createHighlighter({
111
+ themes: [
112
+ // Custom claret themes passed as ThemeRegistration objects
113
+ claretDark,
114
+ claretLight,
115
+ // Vitesse themes loaded by name from the bundled set
116
+ "vitesse-dark",
117
+ "vitesse-light",
118
+ ],
119
+ langs: SUPPORTED_LANGUAGES as string[],
120
+ });
121
+ }
122
+ return highlighterPromise;
123
+ }
124
+
125
+ // ─── Public API ───────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Highlight `code` in language `lang` with the given `theme`.
129
+ * Returns the inner HTML for a `<code>` element: one `<span class="line">` per
130
+ * source line, each containing `<span style="color:...">` token spans.
131
+ *
132
+ * If `lang` is not in SUPPORTED_LANGUAGES, falls back to plain-escaped output
133
+ * wrapped the same way (one `<span class="line">` per line, no color spans).
134
+ *
135
+ * shiki internally escapes `<`, `>`, `&` in token content, so XSS is covered.
136
+ * The plain-text fallback goes through `escapeHtml` for the same guarantee.
137
+ */
138
+ export async function highlightCode(
139
+ code: string,
140
+ lang: string,
141
+ theme: HighlightTheme = "claret-dark",
142
+ ): Promise<string> {
143
+ const supported = SUPPORTED_LANGUAGES.includes(lang);
144
+
145
+ if (!supported) {
146
+ return plainFallback(code);
147
+ }
148
+
149
+ const hi = await getHighlighter();
150
+ const result = hi.codeToTokens(code, { theme, lang: lang as BundledLanguage });
151
+
152
+ return tokensToHtml(result.tokens);
153
+ }
154
+
155
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Render shiki token lines into `<span class="line">` HTML.
159
+ * Token `content` is already HTML-escaped by shiki.
160
+ */
161
+ function tokensToHtml(lines: ThemedToken[][]): string {
162
+ return lines
163
+ .map((line) => {
164
+ const inner = line
165
+ .map((token) => {
166
+ if (token.color !== undefined && token.color !== "") {
167
+ return `<span style="color:${token.color}">${token.content}</span>`;
168
+ }
169
+ // No color info — emit bare content (shiki has already escaped it)
170
+ return token.content;
171
+ })
172
+ .join("");
173
+ return `<span class="line">${inner}</span>`;
174
+ })
175
+ .join("\n");
176
+ }
177
+
178
+ /**
179
+ * Plain-text fallback: escape HTML and wrap each line in a `<span class="line">`.
180
+ * Used when the requested language is not in the supported set.
181
+ */
182
+ function plainFallback(code: string): string {
183
+ const lines = code.split("\n");
184
+ return lines.map((line) => `<span class="line">${escapeHtml(line)}</span>`).join("\n");
185
+ }
@@ -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
  }
@@ -2,6 +2,7 @@
2
2
  // src/render/blocks/render.ts
3
3
 
4
4
  import type { Block } from "./types.ts";
5
+ import type { HighlightTheme } from "./highlight.ts";
5
6
  import { renderHero } from "./renderers/hero.ts";
6
7
  import { renderTldr } from "./renderers/tldr.ts";
7
8
  import { renderSection } from "./renderers/section.ts";
@@ -17,6 +18,7 @@ import { renderPillRow } from "./renderers/pill-row.ts";
17
18
  import { renderDivider } from "./renderers/divider.ts";
18
19
  import { renderDiagram } from "./renderers/diagram.ts";
19
20
  import { renderRawHtml } from "./renderers/raw-html.ts";
21
+ import { renderDiff } from "./renderers/diff.ts";
20
22
 
21
23
  /** Shared mutable counter — all section renderers increment this via the ctx ref. */
22
24
  export interface SectionCounter {
@@ -31,18 +33,21 @@ export interface RenderCtx {
31
33
  depth: number;
32
34
  /** Path string for error messages (e.g. "blocks[2].children[1]"). */
33
35
  path: string;
36
+ /** Shiki highlight theme derived from the active cesium theme preset. */
37
+ highlightTheme: HighlightTheme;
34
38
  }
35
39
 
36
- function makeRootCtx(): RenderCtx {
40
+ function makeRootCtx(highlightTheme: HighlightTheme = "claret-dark"): RenderCtx {
37
41
  return {
38
42
  sectionCounter: { value: 1 },
39
43
  depth: 0,
40
44
  path: "blocks",
45
+ highlightTheme,
41
46
  };
42
47
  }
43
48
 
44
49
  /** Dispatch a single block to its renderer. */
45
- export function renderBlock(block: Block, ctx: RenderCtx): string {
50
+ export async function renderBlock(block: Block, ctx: RenderCtx): Promise<string> {
46
51
  switch (block.type) {
47
52
  case "hero":
48
53
  return renderHero(block, ctx);
@@ -74,12 +79,17 @@ export function renderBlock(block: Block, ctx: RenderCtx): string {
74
79
  return renderDiagram(block, ctx);
75
80
  case "raw_html":
76
81
  return renderRawHtml(block, ctx);
82
+ case "diff":
83
+ return renderDiff(block, ctx);
77
84
  }
78
85
  }
79
86
 
80
87
  /** Render an array of blocks, returning the concatenated HTML body string. */
81
- export function renderBlocks(blocks: Block[], opts?: { title?: string }): string {
82
- const ctx = makeRootCtx();
88
+ export async function renderBlocks(
89
+ blocks: Block[],
90
+ opts?: { title?: string; highlightTheme?: HighlightTheme },
91
+ ): Promise<string> {
92
+ const ctx = makeRootCtx(opts?.highlightTheme);
83
93
  const parts: string[] = [];
84
94
  for (let i = 0; i < blocks.length; i++) {
85
95
  const block = blocks[i];
@@ -88,7 +98,8 @@ export function renderBlocks(blocks: Block[], opts?: { title?: string }): string
88
98
  ...ctx,
89
99
  path: `blocks[${i}]`,
90
100
  };
91
- parts.push(renderBlock(block, blockCtx));
101
+ // eslint-disable-next-line no-await-in-loop -- sequential render required; section counter is a shared mutable ref
102
+ parts.push(await renderBlock(block, blockCtx));
92
103
  }
93
104
  // Unused opts.title kept for API compatibility; wrapDocument handles the title
94
105
  void opts;
@@ -1,12 +1,13 @@
1
- // Code block renderer.
1
+ // Code block renderer — server-side syntax highlighting via shiki.
2
2
  // src/render/blocks/renderers/code.ts
3
3
 
4
4
  import type { CodeBlock } from "../types.ts";
5
5
  import type { BlockMeta } from "../types.ts";
6
6
  import type { RenderCtx } from "../render.ts";
7
7
  import { escapeHtml, escapeAttr } from "../escape.ts";
8
+ import { highlightCode } from "../highlight.ts";
8
9
 
9
- export function renderCode(block: CodeBlock, _ctx: RenderCtx): string {
10
+ export async function renderCode(block: CodeBlock, ctx: RenderCtx): Promise<string> {
10
11
  const parts: string[] = [];
11
12
 
12
13
  const captionText = block.filename ?? block.caption;
@@ -14,9 +15,8 @@ export function renderCode(block: CodeBlock, _ctx: RenderCtx): string {
14
15
  parts.push(` <figcaption>${escapeHtml(captionText)}</figcaption>`);
15
16
  }
16
17
 
17
- parts.push(
18
- ` <pre><code class="lang-${escapeAttr(block.lang)}">${escapeHtml(block.code)}</code></pre>`,
19
- );
18
+ const highlighted = await highlightCode(block.code, block.lang, ctx.highlightTheme);
19
+ parts.push(` <pre><code class="lang-${escapeAttr(block.lang)}">${highlighted}</code></pre>`);
20
20
 
21
21
  return `<figure class="code">\n${parts.join("\n")}\n</figure>`;
22
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",