@cfbender/cesium 0.4.0 → 0.5.1
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 +94 -0
- package/README.md +2 -5
- package/package.json +3 -2
- package/src/cli/commands/serve.ts +18 -2
- package/src/index.ts +4 -1
- package/src/prompt/field-reference.ts +94 -0
- package/src/prompt/system-fragment.md +56 -65
- package/src/render/blocks/catalog.ts +39 -0
- package/src/render/blocks/escape.ts +27 -0
- package/src/render/blocks/highlight.ts +188 -0
- package/src/render/blocks/index.ts +6 -0
- package/src/render/blocks/markdown.ts +217 -0
- package/src/render/blocks/render.ts +104 -0
- package/src/render/blocks/renderers/callout.ts +38 -0
- package/src/render/blocks/renderers/code.ts +46 -0
- package/src/render/blocks/renderers/compare-table.ts +56 -0
- package/src/render/blocks/renderers/diagram.ts +48 -0
- package/src/render/blocks/renderers/divider.ts +31 -0
- package/src/render/blocks/renderers/hero.ts +66 -0
- package/src/render/blocks/renderers/kv.ts +45 -0
- package/src/render/blocks/renderers/list.ts +51 -0
- package/src/render/blocks/renderers/pill-row.ts +45 -0
- package/src/render/blocks/renderers/prose.ts +29 -0
- package/src/render/blocks/renderers/raw-html.ts +32 -0
- package/src/render/blocks/renderers/risk-table.ts +76 -0
- package/src/render/blocks/renderers/section.ts +97 -0
- package/src/render/blocks/renderers/timeline.ts +58 -0
- package/src/render/blocks/renderers/tldr.ts +30 -0
- package/src/render/blocks/themes/claret-dark.ts +206 -0
- package/src/render/blocks/themes/claret-light.ts +227 -0
- package/src/render/blocks/types.ts +127 -0
- package/src/render/blocks/validate-block.ts +202 -0
- package/src/render/critique.ts +410 -10
- package/src/render/fallback.ts +18 -0
- package/src/render/theme.ts +154 -0
- package/src/render/validate.ts +282 -17
- package/src/render/wrap.ts +7 -7
- package/src/server/lifecycle.ts +190 -3
- package/src/storage/assets.ts +66 -0
- package/src/storage/index-cache.ts +1 -0
- package/src/storage/index-gen.ts +13 -14
- package/src/tools/ask.ts +7 -5
- package/src/tools/critique.ts +41 -6
- package/src/tools/publish.ts +43 -14
- package/src/tools/styleguide.ts +118 -9
|
@@ -0,0 +1,188 @@
|
|
|
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 =
|
|
17
|
+
| "claret-dark"
|
|
18
|
+
| "claret-light"
|
|
19
|
+
| "vitesse-dark"
|
|
20
|
+
| "vitesse-light";
|
|
21
|
+
|
|
22
|
+
// ─── Supported languages ─────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The curated language list loaded into the highlighter.
|
|
26
|
+
* ~25 languages covering 95 %+ of real use.
|
|
27
|
+
*/
|
|
28
|
+
export const SUPPORTED_LANGUAGES: readonly string[] = [
|
|
29
|
+
"typescript",
|
|
30
|
+
"ts",
|
|
31
|
+
"tsx",
|
|
32
|
+
"javascript",
|
|
33
|
+
"js",
|
|
34
|
+
"jsx",
|
|
35
|
+
"json",
|
|
36
|
+
"html",
|
|
37
|
+
"css",
|
|
38
|
+
"markdown",
|
|
39
|
+
"md",
|
|
40
|
+
"shellscript",
|
|
41
|
+
"sh",
|
|
42
|
+
"bash",
|
|
43
|
+
"shell",
|
|
44
|
+
"python",
|
|
45
|
+
"py",
|
|
46
|
+
"rust",
|
|
47
|
+
"go",
|
|
48
|
+
"ruby",
|
|
49
|
+
"rb",
|
|
50
|
+
"yaml",
|
|
51
|
+
"yml",
|
|
52
|
+
"sql",
|
|
53
|
+
"toml",
|
|
54
|
+
"dockerfile",
|
|
55
|
+
"diff",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// ─── Theme resolution ─────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Map a cesium theme preset name to the appropriate shiki highlight theme.
|
|
62
|
+
*
|
|
63
|
+
* - "claret" / "claret-dark" → "claret-dark" (custom)
|
|
64
|
+
* - "claret-light" → "claret-light" (custom)
|
|
65
|
+
* - other named preset → vitesse-dark if codeBg is dark, else vitesse-light
|
|
66
|
+
* - unknown / undefined → "claret-dark" (matches framework default in themeFromPreset)
|
|
67
|
+
*/
|
|
68
|
+
export function resolveHighlightTheme(cesiumThemeName: string | undefined): HighlightTheme {
|
|
69
|
+
if (cesiumThemeName === "claret" || cesiumThemeName === "claret-dark") {
|
|
70
|
+
return "claret-dark";
|
|
71
|
+
}
|
|
72
|
+
if (cesiumThemeName === "claret-light") {
|
|
73
|
+
return "claret-light";
|
|
74
|
+
}
|
|
75
|
+
if (cesiumThemeName !== undefined && isThemePresetName(cesiumThemeName)) {
|
|
76
|
+
const palette = THEME_PRESETS[cesiumThemeName];
|
|
77
|
+
// Use the code panel background color to choose the shiki theme.
|
|
78
|
+
// All current non-claret presets have a dark codeBg, but check anyway.
|
|
79
|
+
return isHexDark(palette.codeBg) ? "vitesse-dark" : "vitesse-light";
|
|
80
|
+
}
|
|
81
|
+
// undefined / unknown — match the framework theme default (claret-dark).
|
|
82
|
+
return "claret-dark";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns true if the hex color's perceived luminance is dark (< 0.5).
|
|
87
|
+
* Accepts 3- or 6-digit hex with or without leading '#'.
|
|
88
|
+
*/
|
|
89
|
+
function isHexDark(hex: string): boolean {
|
|
90
|
+
const clean = hex.replace("#", "");
|
|
91
|
+
const full = clean.length === 3
|
|
92
|
+
? clean
|
|
93
|
+
.split("")
|
|
94
|
+
.map((c) => c + c)
|
|
95
|
+
.join("")
|
|
96
|
+
: clean;
|
|
97
|
+
const r = parseInt(full.slice(0, 2), 16);
|
|
98
|
+
const g = parseInt(full.slice(2, 4), 16);
|
|
99
|
+
const b = parseInt(full.slice(4, 6), 16);
|
|
100
|
+
// Simple average luminance threshold
|
|
101
|
+
const avg = (r + g + b) / 3;
|
|
102
|
+
return avg < 128;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Highlighter singleton ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/** Promise cache — concurrent first-calls share one init. */
|
|
108
|
+
let highlighterPromise: Promise<import("shiki").Highlighter> | null = null;
|
|
109
|
+
|
|
110
|
+
async function getHighlighter(): Promise<import("shiki").Highlighter> {
|
|
111
|
+
if (highlighterPromise === null) {
|
|
112
|
+
const { createHighlighter } = await import("shiki");
|
|
113
|
+
highlighterPromise = createHighlighter({
|
|
114
|
+
themes: [
|
|
115
|
+
// Custom claret themes passed as ThemeRegistration objects
|
|
116
|
+
claretDark,
|
|
117
|
+
claretLight,
|
|
118
|
+
// Vitesse themes loaded by name from the bundled set
|
|
119
|
+
"vitesse-dark",
|
|
120
|
+
"vitesse-light",
|
|
121
|
+
],
|
|
122
|
+
langs: SUPPORTED_LANGUAGES as string[],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return highlighterPromise;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Highlight `code` in language `lang` with the given `theme`.
|
|
132
|
+
* Returns the inner HTML for a `<code>` element: one `<span class="line">` per
|
|
133
|
+
* source line, each containing `<span style="color:...">` token spans.
|
|
134
|
+
*
|
|
135
|
+
* If `lang` is not in SUPPORTED_LANGUAGES, falls back to plain-escaped output
|
|
136
|
+
* wrapped the same way (one `<span class="line">` per line, no color spans).
|
|
137
|
+
*
|
|
138
|
+
* shiki internally escapes `<`, `>`, `&` in token content, so XSS is covered.
|
|
139
|
+
* The plain-text fallback goes through `escapeHtml` for the same guarantee.
|
|
140
|
+
*/
|
|
141
|
+
export async function highlightCode(
|
|
142
|
+
code: string,
|
|
143
|
+
lang: string,
|
|
144
|
+
theme: HighlightTheme = "claret-dark",
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
const supported = SUPPORTED_LANGUAGES.includes(lang);
|
|
147
|
+
|
|
148
|
+
if (!supported) {
|
|
149
|
+
return plainFallback(code);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const hi = await getHighlighter();
|
|
153
|
+
const result = hi.codeToTokens(code, { theme, lang: lang as BundledLanguage });
|
|
154
|
+
|
|
155
|
+
return tokensToHtml(result.tokens);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Render shiki token lines into `<span class="line">` HTML.
|
|
162
|
+
* Token `content` is already HTML-escaped by shiki.
|
|
163
|
+
*/
|
|
164
|
+
function tokensToHtml(lines: ThemedToken[][]): string {
|
|
165
|
+
return lines
|
|
166
|
+
.map((line) => {
|
|
167
|
+
const inner = line
|
|
168
|
+
.map((token) => {
|
|
169
|
+
if (token.color !== undefined && token.color !== "") {
|
|
170
|
+
return `<span style="color:${token.color}">${token.content}</span>`;
|
|
171
|
+
}
|
|
172
|
+
// No color info — emit bare content (shiki has already escaped it)
|
|
173
|
+
return token.content;
|
|
174
|
+
})
|
|
175
|
+
.join("");
|
|
176
|
+
return `<span class="line">${inner}</span>`;
|
|
177
|
+
})
|
|
178
|
+
.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Plain-text fallback: escape HTML and wrap each line in a `<span class="line">`.
|
|
183
|
+
* Used when the requested language is not in the supported set.
|
|
184
|
+
*/
|
|
185
|
+
function plainFallback(code: string): string {
|
|
186
|
+
const lines = code.split("\n");
|
|
187
|
+
return lines.map((line) => `<span class="line">${escapeHtml(line)}</span>`).join("\n");
|
|
188
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Owned markdown subset — no external dependencies.
|
|
2
|
+
// src/render/blocks/markdown.ts
|
|
3
|
+
//
|
|
4
|
+
// Block-level: paragraph, bullet list (- ), ordered list (1. ),
|
|
5
|
+
// blockquote (> ), horizontal rule (---), hard break (two-space EOL).
|
|
6
|
+
// Inline: **bold**, *italic*, `code`, [text](href) — external hrefs → plain text.
|
|
7
|
+
// HTML safelist: <kbd>, <span class="pill">, <span class="tag">.
|
|
8
|
+
// Everything else is HTML-escaped.
|
|
9
|
+
|
|
10
|
+
import { escapeHtml, escapeAttr } from "./escape.ts";
|
|
11
|
+
|
|
12
|
+
// ─── Safelist placeholder pass ───────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const SAFELIST_RE =
|
|
15
|
+
/<(kbd)>(.*?)<\/kbd>|<span\s+class="(pill|tag)">(.*?)<\/span>/gi;
|
|
16
|
+
|
|
17
|
+
function extractSafelist(input: string): { text: string; map: Map<string, string> } {
|
|
18
|
+
const map = new Map<string, string>();
|
|
19
|
+
let counter = 0;
|
|
20
|
+
const text = input.replace(SAFELIST_RE, (...args: unknown[]) => {
|
|
21
|
+
// args: [fullMatch, kbdTag, kbdContent, spanClass, spanContent, offset, string]
|
|
22
|
+
const full = args[0] as string;
|
|
23
|
+
const kbdTag = args[1] as string | undefined;
|
|
24
|
+
const kbdContent = args[2] as string | undefined;
|
|
25
|
+
const spanClass = args[3] as string | undefined;
|
|
26
|
+
const spanContent = args[4] as string | undefined;
|
|
27
|
+
|
|
28
|
+
let html: string;
|
|
29
|
+
if (kbdTag !== undefined && kbdContent !== undefined) {
|
|
30
|
+
html = `<kbd>${escapeHtml(kbdContent)}</kbd>`;
|
|
31
|
+
} else if (spanClass !== undefined && spanContent !== undefined) {
|
|
32
|
+
html = `<span class="${escapeAttr(spanClass)}">${escapeHtml(spanContent)}</span>`;
|
|
33
|
+
} else {
|
|
34
|
+
// Shouldn't happen, but fall back to escaping the whole match
|
|
35
|
+
html = escapeHtml(full);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const id = `\x00SAFE${counter++}\x00`;
|
|
39
|
+
map.set(id, html);
|
|
40
|
+
return id;
|
|
41
|
+
});
|
|
42
|
+
return { text, map };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function restoreSafelist(html: string, map: Map<string, string>): string {
|
|
46
|
+
let result = html;
|
|
47
|
+
for (const [id, replacement] of map) {
|
|
48
|
+
result = result.split(id).join(replacement);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Inline rendering ────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const RELATIVE_HREF_RE = /^([/#]|[^:/?#]*[/?#]|[^:/?#]+$)/;
|
|
56
|
+
|
|
57
|
+
function renderInline(text: string): string {
|
|
58
|
+
// Step 1: extract inline code spans before any escaping
|
|
59
|
+
const codeMap = new Map<string, string>();
|
|
60
|
+
let codeCounter = 0;
|
|
61
|
+
let working = text.replace(/`([^`]+)`/g, (_m, inner: string) => {
|
|
62
|
+
const id = `\x00CODE${codeCounter++}\x00`;
|
|
63
|
+
codeMap.set(id, `<code>${escapeHtml(inner)}</code>`);
|
|
64
|
+
return id;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Step 2: escape remaining HTML
|
|
68
|
+
working = working.replace(/&/g, "&");
|
|
69
|
+
working = working.replace(/</g, "<").replace(/>/g, ">");
|
|
70
|
+
|
|
71
|
+
// Step 3: process other inline patterns
|
|
72
|
+
// **bold**
|
|
73
|
+
working = working.replace(/\*\*(.+?)\*\*/g, (_m, inner: string) => `<strong>${inner}</strong>`);
|
|
74
|
+
// *italic* (not preceded by another *)
|
|
75
|
+
working = working.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, (_m, inner: string) => `<em>${inner}</em>`);
|
|
76
|
+
// [text](href) — only relative/anchor hrefs; external → plain text
|
|
77
|
+
working = working.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText: string, href: string) => {
|
|
78
|
+
if (RELATIVE_HREF_RE.test(href)) {
|
|
79
|
+
return `<a href="${escapeAttr(href)}">${linkText}</a>`;
|
|
80
|
+
}
|
|
81
|
+
return linkText;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Step 4: restore code spans and hard break placeholders
|
|
85
|
+
for (const [id, html] of codeMap) {
|
|
86
|
+
working = working.split(id).join(html);
|
|
87
|
+
}
|
|
88
|
+
working = working.split(HARD_BREAK_PLACEHOLDER).join("<br>");
|
|
89
|
+
|
|
90
|
+
return working;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Block-level rendering ───────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
type Line = string;
|
|
96
|
+
|
|
97
|
+
function isHr(line: Line): boolean {
|
|
98
|
+
return /^-{3,}\s*$/.test(line) || /^\*{3,}\s*$/.test(line) || /^_{3,}\s*$/.test(line);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isBullet(line: Line): boolean {
|
|
102
|
+
return /^[ \t]*[-*+]\s+/.test(line);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isOrdered(line: Line): boolean {
|
|
106
|
+
return /^[ \t]*\d+\.\s+/.test(line);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isBlockquote(line: Line): boolean {
|
|
110
|
+
return /^[ \t]*>\s?/.test(line);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isBlank(line: Line): boolean {
|
|
114
|
+
return line.trim() === "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getBulletContent(line: Line): string {
|
|
118
|
+
return line.replace(/^[ \t]*[-*+]\s+/, "");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getOrderedContent(line: Line): string {
|
|
122
|
+
return line.replace(/^[ \t]*\d+\.\s+/, "");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getBlockquoteContent(line: Line): string {
|
|
126
|
+
return line.replace(/^[ \t]*>\s?/, "");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Applies hard-break rule: line ending with two spaces → placeholder, restored after escaping */
|
|
130
|
+
const HARD_BREAK_PLACEHOLDER = "\x00BR\x00";
|
|
131
|
+
|
|
132
|
+
function applyHardBreak(line: string): string {
|
|
133
|
+
if (line.endsWith(" ")) {
|
|
134
|
+
return line.slice(0, -2) + HARD_BREAK_PLACEHOLDER;
|
|
135
|
+
}
|
|
136
|
+
return line;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderMarkdown(md: string): string {
|
|
140
|
+
const { text: safeText, map: safeMap } = extractSafelist(md);
|
|
141
|
+
const lines = safeText.split("\n");
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
let i = 0;
|
|
144
|
+
|
|
145
|
+
while (i < lines.length) {
|
|
146
|
+
const line = lines[i] ?? "";
|
|
147
|
+
|
|
148
|
+
if (isBlank(line)) {
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (isHr(line)) {
|
|
154
|
+
parts.push("<hr>");
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Bullet list
|
|
160
|
+
if (isBullet(line)) {
|
|
161
|
+
const items: string[] = [];
|
|
162
|
+
while (i < lines.length && (isBullet(lines[i] ?? "") || (!isBlank(lines[i] ?? "") && /^[ \t]{2,}/.test(lines[i] ?? "")))) {
|
|
163
|
+
const cur = lines[i] ?? "";
|
|
164
|
+
if (isBullet(cur)) {
|
|
165
|
+
items.push(renderInline(applyHardBreak(getBulletContent(cur))));
|
|
166
|
+
}
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
parts.push(`<ul>\n${items.map((it) => ` <li>${it}</li>`).join("\n")}\n</ul>`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Ordered list
|
|
174
|
+
if (isOrdered(line)) {
|
|
175
|
+
const items: string[] = [];
|
|
176
|
+
while (i < lines.length && (isOrdered(lines[i] ?? "") || (!isBlank(lines[i] ?? "") && /^[ \t]{2,}/.test(lines[i] ?? "")))) {
|
|
177
|
+
const cur = lines[i] ?? "";
|
|
178
|
+
if (isOrdered(cur)) {
|
|
179
|
+
items.push(renderInline(applyHardBreak(getOrderedContent(cur))));
|
|
180
|
+
}
|
|
181
|
+
i++;
|
|
182
|
+
}
|
|
183
|
+
parts.push(`<ol>\n${items.map((it) => ` <li>${it}</li>`).join("\n")}\n</ol>`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Blockquote
|
|
188
|
+
if (isBlockquote(line)) {
|
|
189
|
+
const bqLines: string[] = [];
|
|
190
|
+
while (i < lines.length && (isBlockquote(lines[i] ?? "") || (!isBlank(lines[i] ?? "") && !/^[-*]/.test(lines[i] ?? "")))) {
|
|
191
|
+
const cur = lines[i] ?? "";
|
|
192
|
+
if (isBlockquote(cur)) {
|
|
193
|
+
bqLines.push(renderInline(applyHardBreak(getBlockquoteContent(cur))));
|
|
194
|
+
} else {
|
|
195
|
+
bqLines.push(renderInline(applyHardBreak(cur)));
|
|
196
|
+
}
|
|
197
|
+
i++;
|
|
198
|
+
}
|
|
199
|
+
parts.push(`<blockquote>${bqLines.join("<br>")}</blockquote>`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Paragraph — collect until blank or block-level
|
|
204
|
+
const paraLines: string[] = [];
|
|
205
|
+
while (i < lines.length && !isBlank(lines[i] ?? "") && !isHr(lines[i] ?? "") && !isBullet(lines[i] ?? "") && !isOrdered(lines[i] ?? "") && !isBlockquote(lines[i] ?? "")) {
|
|
206
|
+
paraLines.push(applyHardBreak(lines[i] ?? ""));
|
|
207
|
+
i++;
|
|
208
|
+
}
|
|
209
|
+
if (paraLines.length > 0) {
|
|
210
|
+
const inner = paraLines.map((l) => renderInline(l)).join("\n");
|
|
211
|
+
parts.push(`<p>${inner}</p>`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const result = parts.join("\n");
|
|
216
|
+
return restoreSafelist(result, safeMap);
|
|
217
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Block tree walker and dispatcher.
|
|
2
|
+
// src/render/blocks/render.ts
|
|
3
|
+
|
|
4
|
+
import type { Block } from "./types.ts";
|
|
5
|
+
import type { HighlightTheme } from "./highlight.ts";
|
|
6
|
+
import { renderHero } from "./renderers/hero.ts";
|
|
7
|
+
import { renderTldr } from "./renderers/tldr.ts";
|
|
8
|
+
import { renderSection } from "./renderers/section.ts";
|
|
9
|
+
import { renderProse } from "./renderers/prose.ts";
|
|
10
|
+
import { renderList } from "./renderers/list.ts";
|
|
11
|
+
import { renderCallout } from "./renderers/callout.ts";
|
|
12
|
+
import { renderCode } from "./renderers/code.ts";
|
|
13
|
+
import { renderTimeline } from "./renderers/timeline.ts";
|
|
14
|
+
import { renderCompareTable } from "./renderers/compare-table.ts";
|
|
15
|
+
import { renderRiskTable } from "./renderers/risk-table.ts";
|
|
16
|
+
import { renderKv } from "./renderers/kv.ts";
|
|
17
|
+
import { renderPillRow } from "./renderers/pill-row.ts";
|
|
18
|
+
import { renderDivider } from "./renderers/divider.ts";
|
|
19
|
+
import { renderDiagram } from "./renderers/diagram.ts";
|
|
20
|
+
import { renderRawHtml } from "./renderers/raw-html.ts";
|
|
21
|
+
|
|
22
|
+
/** Shared mutable counter — all section renderers increment this via the ctx ref. */
|
|
23
|
+
export interface SectionCounter {
|
|
24
|
+
value: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Shared render context threaded through the tree walk. */
|
|
28
|
+
export interface RenderCtx {
|
|
29
|
+
/** Shared auto-incrementing section counter (1-based). Mutable ref. */
|
|
30
|
+
sectionCounter: SectionCounter;
|
|
31
|
+
/** Current nesting depth (root = 0, inside section = 1, etc.). */
|
|
32
|
+
depth: number;
|
|
33
|
+
/** Path string for error messages (e.g. "blocks[2].children[1]"). */
|
|
34
|
+
path: string;
|
|
35
|
+
/** Shiki highlight theme derived from the active cesium theme preset. */
|
|
36
|
+
highlightTheme: HighlightTheme;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeRootCtx(highlightTheme: HighlightTheme = "claret-dark"): RenderCtx {
|
|
40
|
+
return {
|
|
41
|
+
sectionCounter: { value: 1 },
|
|
42
|
+
depth: 0,
|
|
43
|
+
path: "blocks",
|
|
44
|
+
highlightTheme,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Dispatch a single block to its renderer. */
|
|
49
|
+
export async function renderBlock(block: Block, ctx: RenderCtx): Promise<string> {
|
|
50
|
+
switch (block.type) {
|
|
51
|
+
case "hero":
|
|
52
|
+
return renderHero(block, ctx);
|
|
53
|
+
case "tldr":
|
|
54
|
+
return renderTldr(block, ctx);
|
|
55
|
+
case "section":
|
|
56
|
+
return renderSection(block, ctx);
|
|
57
|
+
case "prose":
|
|
58
|
+
return renderProse(block, ctx);
|
|
59
|
+
case "list":
|
|
60
|
+
return renderList(block, ctx);
|
|
61
|
+
case "callout":
|
|
62
|
+
return renderCallout(block, ctx);
|
|
63
|
+
case "code":
|
|
64
|
+
return renderCode(block, ctx);
|
|
65
|
+
case "timeline":
|
|
66
|
+
return renderTimeline(block, ctx);
|
|
67
|
+
case "compare_table":
|
|
68
|
+
return renderCompareTable(block, ctx);
|
|
69
|
+
case "risk_table":
|
|
70
|
+
return renderRiskTable(block, ctx);
|
|
71
|
+
case "kv":
|
|
72
|
+
return renderKv(block, ctx);
|
|
73
|
+
case "pill_row":
|
|
74
|
+
return renderPillRow(block, ctx);
|
|
75
|
+
case "divider":
|
|
76
|
+
return renderDivider(block, ctx);
|
|
77
|
+
case "diagram":
|
|
78
|
+
return renderDiagram(block, ctx);
|
|
79
|
+
case "raw_html":
|
|
80
|
+
return renderRawHtml(block, ctx);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Render an array of blocks, returning the concatenated HTML body string. */
|
|
85
|
+
export async function renderBlocks(
|
|
86
|
+
blocks: Block[],
|
|
87
|
+
opts?: { title?: string; highlightTheme?: HighlightTheme },
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const ctx = makeRootCtx(opts?.highlightTheme);
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
92
|
+
const block = blocks[i];
|
|
93
|
+
if (block === undefined) continue;
|
|
94
|
+
const blockCtx: RenderCtx = {
|
|
95
|
+
...ctx,
|
|
96
|
+
path: `blocks[${i}]`,
|
|
97
|
+
};
|
|
98
|
+
// eslint-disable-next-line no-await-in-loop -- sequential render required; section counter is a shared mutable ref
|
|
99
|
+
parts.push(await renderBlock(block, blockCtx));
|
|
100
|
+
}
|
|
101
|
+
// Unused opts.title kept for API compatibility; wrapDocument handles the title
|
|
102
|
+
void opts;
|
|
103
|
+
return parts.join("\n");
|
|
104
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Callout block renderer.
|
|
2
|
+
// src/render/blocks/renderers/callout.ts
|
|
3
|
+
|
|
4
|
+
import type { CalloutBlock } from "../types.ts";
|
|
5
|
+
import type { BlockMeta } from "../types.ts";
|
|
6
|
+
import type { RenderCtx } from "../render.ts";
|
|
7
|
+
import { escapeHtml } from "../escape.ts";
|
|
8
|
+
import { renderMarkdown } from "../markdown.ts";
|
|
9
|
+
|
|
10
|
+
export function renderCallout(block: CalloutBlock, _ctx: RenderCtx): string {
|
|
11
|
+
const titleHtml =
|
|
12
|
+
block.title !== undefined && block.title !== ""
|
|
13
|
+
? `<strong>${escapeHtml(block.title)}</strong> `
|
|
14
|
+
: "";
|
|
15
|
+
const contentHtml = renderMarkdown(block.markdown);
|
|
16
|
+
return `<aside class="callout ${block.variant}">\n${titleHtml}${contentHtml}\n</aside>`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const meta: BlockMeta = {
|
|
20
|
+
type: "callout",
|
|
21
|
+
description: "Highlighted aside with variant styling. Variants: note, warn, risk.",
|
|
22
|
+
schema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
type: { const: "callout" },
|
|
26
|
+
variant: { type: "string", enum: ["note", "warn", "risk"] },
|
|
27
|
+
title: { type: "string" },
|
|
28
|
+
markdown: { type: "string" },
|
|
29
|
+
},
|
|
30
|
+
required: ["type", "variant", "markdown"],
|
|
31
|
+
},
|
|
32
|
+
example: {
|
|
33
|
+
type: "callout",
|
|
34
|
+
variant: "warn",
|
|
35
|
+
title: "Breaking Change",
|
|
36
|
+
markdown: "This change removes the `html` fallback path. Migrate to `blocks` before upgrading.",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Code block renderer — server-side syntax highlighting via shiki.
|
|
2
|
+
// src/render/blocks/renderers/code.ts
|
|
3
|
+
|
|
4
|
+
import type { CodeBlock } from "../types.ts";
|
|
5
|
+
import type { BlockMeta } from "../types.ts";
|
|
6
|
+
import type { RenderCtx } from "../render.ts";
|
|
7
|
+
import { escapeHtml, escapeAttr } from "../escape.ts";
|
|
8
|
+
import { highlightCode } from "../highlight.ts";
|
|
9
|
+
|
|
10
|
+
export async function renderCode(block: CodeBlock, ctx: RenderCtx): Promise<string> {
|
|
11
|
+
const parts: string[] = [];
|
|
12
|
+
|
|
13
|
+
const captionText = block.filename ?? block.caption;
|
|
14
|
+
if (captionText !== undefined && captionText !== "") {
|
|
15
|
+
parts.push(` <figcaption>${escapeHtml(captionText)}</figcaption>`);
|
|
16
|
+
}
|
|
17
|
+
|
|
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
|
+
);
|
|
22
|
+
|
|
23
|
+
return `<figure class="code">\n${parts.join("\n")}\n</figure>`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const meta: BlockMeta = {
|
|
27
|
+
type: "code",
|
|
28
|
+
description: "Code block with syntax language label and optional filename/caption.",
|
|
29
|
+
schema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
type: { const: "code" },
|
|
33
|
+
lang: { type: "string" },
|
|
34
|
+
code: { type: "string" },
|
|
35
|
+
filename: { type: "string" },
|
|
36
|
+
caption: { type: "string" },
|
|
37
|
+
},
|
|
38
|
+
required: ["type", "lang", "code"],
|
|
39
|
+
},
|
|
40
|
+
example: {
|
|
41
|
+
type: "code",
|
|
42
|
+
lang: "typescript",
|
|
43
|
+
filename: "src/index.ts",
|
|
44
|
+
code: 'import { createPublishTool } from "./tools/publish.ts";\nexport { createPublishTool };',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// CompareTable block renderer.
|
|
2
|
+
// src/render/blocks/renderers/compare-table.ts
|
|
3
|
+
|
|
4
|
+
import type { CompareTableBlock } from "../types.ts";
|
|
5
|
+
import type { BlockMeta } from "../types.ts";
|
|
6
|
+
import type { RenderCtx } from "../render.ts";
|
|
7
|
+
import { escapeHtml } from "../escape.ts";
|
|
8
|
+
import { renderMarkdown } from "../markdown.ts";
|
|
9
|
+
|
|
10
|
+
export function renderCompareTable(block: CompareTableBlock, _ctx: RenderCtx): string {
|
|
11
|
+
const headerCells = block.headers
|
|
12
|
+
.map((h) => ` <th>${escapeHtml(h)}</th>`)
|
|
13
|
+
.join("\n");
|
|
14
|
+
|
|
15
|
+
const bodyRows = block.rows
|
|
16
|
+
.map((row) => {
|
|
17
|
+
const cells = row
|
|
18
|
+
.map((cell) => {
|
|
19
|
+
const content = renderMarkdown(cell).replace(/^<p>|<\/p>$/g, "");
|
|
20
|
+
return ` <td>${content}</td>`;
|
|
21
|
+
})
|
|
22
|
+
.join("\n");
|
|
23
|
+
return ` <tr>\n${cells}\n </tr>`;
|
|
24
|
+
})
|
|
25
|
+
.join("\n");
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
`<table class="compare-table">\n` +
|
|
29
|
+
` <thead>\n <tr>\n${headerCells}\n </tr>\n </thead>\n` +
|
|
30
|
+
` <tbody>\n${bodyRows}\n </tbody>\n` +
|
|
31
|
+
`</table>`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const meta: BlockMeta = {
|
|
36
|
+
type: "compare_table",
|
|
37
|
+
description: "Bordered comparison grid. Headers define columns; rows must have matching cell count.",
|
|
38
|
+
schema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
type: { const: "compare_table" },
|
|
42
|
+
headers: { type: "array", items: { type: "string" } },
|
|
43
|
+
rows: { type: "array", items: { type: "array", items: { type: "string" } } },
|
|
44
|
+
},
|
|
45
|
+
required: ["type", "headers", "rows"],
|
|
46
|
+
},
|
|
47
|
+
example: {
|
|
48
|
+
type: "compare_table",
|
|
49
|
+
headers: ["Feature", "html mode", "blocks mode"],
|
|
50
|
+
rows: [
|
|
51
|
+
["Token cost", "High", "**~2× lower**"],
|
|
52
|
+
["Type safety", "None", "Full"],
|
|
53
|
+
["Escape hatch", "`raw_html`", "`raw_html`"],
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
};
|