@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.
- package/CHANGELOG.md +86 -1
- package/README.md +8 -8
- package/package.json +19 -18
- package/src/cli/commands/serve.ts +16 -4
- 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 +185 -0
- package/src/render/blocks/markdown.ts +28 -7
- package/src/render/blocks/render.ts +16 -5
- package/src/render/blocks/renderers/code.ts +5 -5
- 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/section.ts +4 -2
- package/src/render/blocks/renderers/timeline.ts +2 -1
- package/src/render/blocks/themes/claret-dark.ts +201 -0
- package/src/render/blocks/themes/claret-light.ts +222 -0
- 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 +188 -3
- package/src/storage/index-gen.ts +2 -3
- package/src/tools/ask.ts +2 -2
- package/src/tools/publish.ts +6 -6
- package/src/tools/styleguide.ts +25 -20
|
@@ -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(
|
|
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
|
}
|
|
@@ -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(
|
|
82
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
18
|
-
|
|
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:
|
|
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",
|