1import {
2 ExpressiveCodeTheme,
3 type AnnotationRenderOptions,
4 type ExpressiveCodePlugin,
5} from "@astrojs/starlight/expressive-code";
6
7/**
8 * Alchemy "walnut sunrise" Expressive Code theme.
9 *
10 * Dark walnut bg (#2a2620) + sunrise syntax tokens that match the design
11 * system's `--alc-code-*` variables. Designed for legibility on cream
12 * parchment pages where every other surface is light.
13 */
14export const alchemyWalnutTheme = new ExpressiveCodeTheme({
15 name: "alchemy-walnut-sunrise",
16 type: "dark",
17 semanticHighlighting: true,
18 colors: {
19 "editor.background": "#2a2620",
20 "editor.foreground": "#faf5e3",
21 "editor.lineHighlightBackground": "#36302280",
22 "editor.selectionBackground": "#5c7a3e66",
23 "editorLineNumber.foreground": "#85714f",
24 "editorLineNumber.activeForeground": "#faf5e3",
25 "editorIndentGuide.background": "#4e402c",
26 "editorIndentGuide.activeBackground": "#68573c",
27 "editorBracketMatch.background": "#5c7a3e33",
28 "editorBracketMatch.border": "#5c7a3e",
29 "editorWidget.background": "#363022",
30 "editorWidget.border": "#4e402c",
31 "editorHoverWidget.background": "#363022",
32 "editorHoverWidget.border": "#4e402c",
33 "editorGroupHeader.tabsBackground": "#1a1813",
34 "tab.activeBackground": "#2a2620",
35 "tab.inactiveBackground": "#1a1813",
36 "tab.activeForeground": "#faf5e3",
37 "tab.inactiveForeground": "#a89572",
38 "tab.border": "#4e402c",
39 focusBorder: "#5c7a3e",
40 "scrollbarSlider.background": "#4e402c80",
41 "scrollbarSlider.hoverBackground": "#68573c80",
42 "scrollbarSlider.activeBackground": "#85714f80",
43 "diffEditor.insertedTextBackground": "#5c7a3e26",
44 "diffEditor.removedTextBackground": "#b3462e26",
45 },
46 tokenColors: [
47 {
48 scope: ["comment", "punctuation.definition.comment", "string.comment"],
49 settings: { foreground: "#b3a27a", fontStyle: "italic" },
50 },
51 {
52 scope: [
53 "keyword",
54 "storage",
55 "storage.type",
56 "storage.modifier",
57 "keyword.control",
58 "keyword.operator.new",
59 "keyword.operator.expression",
60 "keyword.other",
61 ],
62 settings: { foreground: "#d4f26a" },
63 },
64 {
65 scope: [
66 "keyword.operator",
67 "punctuation.separator",
68 "punctuation.terminator",
69 ],
70 settings: { foreground: "#c7b795" },
71 },
72 {
73 scope: ["string", "string.quoted", "punctuation.definition.string"],
74 settings: { foreground: "#ffe38a" },
75 },
76 {
77 scope: ["string.template", "punctuation.definition.template-expression"],
78 settings: { foreground: "#ffe38a" },
79 },
80 {
81 scope: ["constant.numeric", "constant.language", "constant.character"],
82 settings: { foreground: "#ff9a6b" },
83 },
84 {
85 scope: [
86 "constant.language.boolean",
87 "constant.language.null",
88 "constant.language.undefined",
89 ],
90 settings: { foreground: "#ff9a6b" },
91 },
92 {
93 scope: [
94 "entity.name.function",
95 "support.function",
96 "meta.function-call entity.name.function",
97 "meta.function-call.method entity.name.function",
98 "variable.function",
99 "meta.definition.method entity.name.function",
100 ],
101 settings: { foreground: "#7ddfff" },
102 },
103 {
104 scope: [
105 "entity.name.type",
106 "entity.name.class",
107 "entity.name.interface",
108 "entity.name.namespace",
109 "support.type",
110 "support.class",
111 "support.other.namespace",
112 "support.module",
113 "meta.type",
114 ],
115 settings: { foreground: "#7ddfff" },
116 },
117 {
118 scope: [
119 "variable.other.object",
120 "variable.other.readwrite.alias",
121 "meta.import variable.other.readwrite",
122 "meta.export variable.other.readwrite",
123 "meta.object-literal.key support.type.object",
124 ],
125 settings: { foreground: "#7ddfff" },
126 },
127 {
128 scope: ["entity.name.tag", "meta.tag entity.name.tag"],
129 settings: { foreground: "#ffb968" },
130 },
131 {
132 scope: ["entity.other.attribute-name"],
133 settings: { foreground: "#d4f26a", fontStyle: "italic" },
134 },
135 {
136 scope: [
137 "variable",
138 "variable.other",
139 "variable.parameter",
140 "meta.definition.variable variable.other",
141 ],
142 settings: { foreground: "#faf5e3" },
143 },
144 {
145 scope: ["variable.other.constant"],
146 settings: { foreground: "#7ddfff" },
147 },
148 {
149 scope: ["variable.other.enummember"],
150 settings: { foreground: "#ff9a6b" },
151 },
152 {
153 scope: ["variable.other.property", "meta.object.member"],
154 settings: { foreground: "#faf5e3" },
155 },
156 {
157 scope: ["support.variable", "support.constant"],
158 settings: { foreground: "#7ddfff" },
159 },
160 {
161 scope: ["punctuation.section.embedded", "meta.embedded"],
162 settings: { foreground: "#faf5e3" },
163 },
164 {
165 scope: ["markup.heading", "markup.bold"],
166 settings: { foreground: "#faf5e3", fontStyle: "bold" },
167 },
168 {
169 scope: ["markup.italic"],
170 settings: { foreground: "#faf5e3", fontStyle: "italic" },
171 },
172 {
173 scope: ["markup.inserted", "markup.inserted.diff"],
174 settings: { foreground: "#8fb15e" },
175 },
176 {
177 scope: ["markup.deleted", "markup.deleted.diff"],
178 settings: { foreground: "#e07a5f" },
179 },
180 {
181 scope: ["invalid", "invalid.illegal"],
182 settings: { foreground: "#e07a5f" },
183 },
184 ],
185});
186
187import { InlineStyleAnnotation } from "@astrojs/starlight/expressive-code";
188
189const TARGET_LANGS = new Set([
190 "ts",
191 "tsx",
192 "typescript",
193 "js",
194 "jsx",
195 "javascript",
196 "mts",
197 "cts",
198]);
199
200const CAP_IDENT_RE = /\b[A-Z][A-Za-z0-9_]*\b/g;
201
202const CYAN = "#7ddfff";
203const STRING_COLOR = "#ffe38a";
204const COMMENT_COLOR = "#b3a27a";
205
206const eq = (a: string, b: string) =>
207 (a || "").toLowerCase() === b.toLowerCase();
208
209/**
210 * Expressive Code plugin that paints every bare capitalized identifier
211 * in TS/JS code blocks with the "type cyan" used by the marketing
212 * highlighter (`src/components/marketing/highlightTS.ts`).
213 *
214 * Why: the TextMate TS grammar shipped with shiki does NOT tokenize
215 * namespace references in expression position — `Alchemy` and `Effect`
216 * inside `Alchemy.Stack(...)` and `Effect.gen(...)` come out as plain
217 * untokenized text and fall back to the editor foreground. The
218 * marketing landing page colors every capitalized identifier cyan,
219 * giving snippets a distinct "type-y" rhythm. This plugin re-applies
220 * that same rule to docs code blocks so syntax matches across the
221 * whole site.
222 *
223 * The plugin runs AFTER syntax highlighting (which adds its own
224 * `InlineStyleAnnotation`s with the walnut-sunrise colors), and skips
225 * matches that overlap an existing string or comment annotation so
226 * we don't recolor things like `"MyApp"` inside a string literal.
227 */
228export function capitalizedIdentifierColor(): ExpressiveCodePlugin {
229 return {
230 name: "capitalized-identifier-color",
231 hooks: {
232 postprocessAnalyzedCode({ codeBlock }) {
233 if (!TARGET_LANGS.has(codeBlock.language)) return;
234
235 for (const line of codeBlock.getLines()) {
236 // Snapshot column ranges that already belong to strings or
237 // comments — we don't want to recolor characters inside them
238 // (e.g. the `MyApp` in `Alchemy.Stack("MyApp", ...)`).
239 const skipRanges = [];
240 for (const ann of line.getAnnotations()) {
241 if (!ann.inlineRange) continue;
242 // @ts-expect-error verify
243 const c = ann.color;
244 if (eq(c, STRING_COLOR) || eq(c, COMMENT_COLOR)) {
245 skipRanges.push(ann.inlineRange);
246 }
247 }
248
249 const text = line.text;
250 CAP_IDENT_RE.lastIndex = 0;
251 let m;
252 while ((m = CAP_IDENT_RE.exec(text)) !== null) {
253 const columnStart = m.index;
254 const columnEnd = columnStart + m[0].length;
255 const overlapsSkip = skipRanges.some(
256 (r) => columnStart < r.columnEnd && columnEnd > r.columnStart,
257 );
258 if (overlapsSkip) continue;
259
260 line.addAnnotation(
261 new InlineStyleAnnotation({
262 color: CYAN,
263 inlineRange: { columnStart, columnEnd },
264 // `normal` phase: runs after syntax highlighting
265 // (`earliest`), so the cyan wraps and overrides any
266 // walnut-sunrise color the tokenizer applied.
267 renderPhase: "normal",
268 }),
269 );
270 }
271 }
272 },
273 },
274 };
275}
276
277import { ExpressiveCodeAnnotation } from "@astrojs/starlight/expressive-code";
278import { getClassNames, h } from "@astrojs/starlight/expressive-code/hast";
279
280/** Matches `// @error: <text>`, tolerating a leading diff marker. */
281const ERROR_COMMENT = /^(?:[+-](?=[\s/]))?\s*\/\/ @error: ?(.*)$/;
282
283/** Extracts an optional `ts(NNNN)` code from the start of the message. */
284const TS_CODE = /^ts\((\d+)\):?\s*/;
285
286/** The line's token container (`.code` child), or the node itself. */
287function codeContainer(node: any) {
288 return (
289 node.children?.find(
290 (c: any) => c.type === "element" && getClassNames(c).includes("code"),
291 ) ?? node
292 );
293}
294
295class ErrorUnderlineAnnotation extends ExpressiveCodeAnnotation {
296 render({ nodesToTransform }: AnnotationRenderOptions) {
297 return nodesToTransform.map((node) => {
298 if (node.type !== "element") return node;
299 // Wrap the line's tokens in a squiggle span rather than classing the
300 // container: the error box is appended to the same container below,
301 // and `text-decoration` propagates to descendants (it can't be
302 // cancelled from inside), so the box must be a sibling of the
303 // underlined span, not its child.
304 const code = codeContainer(node);
305 code.children = [
306 h("span.twoslash.twoslash-error-underline", code.children ?? []),
307 ];
308 return node;
309 });
310 }
311}
312
313class ErrorBoxAnnotation extends ExpressiveCodeAnnotation {
314 title?: string;
315 message: string;
316
317 constructor(title: string | undefined, message: string) {
318 super({ renderPhase: "latest" });
319 this.title = title;
320 this.message = message;
321 }
322 render({ nodesToTransform }: AnnotationRenderOptions) {
323 return nodesToTransform.map((node) => {
324 if (node.type !== "element") return node;
325 // Append inside `.code` (a block container): a block-level flex box
326 // after inline token content renders on its own line below the code,
327 // exactly like twoslash's markup did. Appending to the `.ec-line`
328 // instead puts the box on the same flex row as the code.
329 codeContainer(node).children.push(
330 h("div.twoslash-error-box.twoslash-error-level-error", [
331 h("span.twoslash-error-box-icon"),
332 h("span.twoslash-error-box-content", [
333 ...(this.title
334 ? [h("span.twoslash-error-box-content-title", this.title)]
335 : []),
336 h("span.twoslash-error-box-content-message", this.message),
337 ]),
338 ]),
339 );
340 return node;
341 });
342 }
343}
344
345/**
346 * Compiler-free type-error annotations for code blocks.
347 *
348 * Replaces expressive-code-twoslash's error rendering (the part of twoslash
349 * we actually rely on) without spinning up a TypeScript program per block.
350 * Docs author errors as plain comments inside any fenced code block, so the
351 * MDX source stays readable as code — agents ingesting the raw markdown see
352 * a normal comment stating that (and why) the code fails to compile:
353 *
354 * ```typescript
355 * const providers: Layer<Providers> = Layer.empty;
356 * // @error: ts(2322) Type 'Layer<never, never, never>' is not assignable
357 * // @error: to type 'Layer<NoInfer<Providers>, never, StackServices>'.
358 * ```
359 *
360 * Rules:
361 * - A `// @error:` comment group annotates the nearest preceding code line:
362 * that line gets a red squiggle and the error box renders directly below
363 * it (exactly where the comment sits in the source).
364 * - Consecutive `// @error:` lines merge into one message. Line breaks
365 * collapse when the box renders (white-space: normal), so wrapping long
366 * messages across several comment lines is purely cosmetic in the source.
367 * - A leading `ts(NNNN)` on the first line becomes the box title
368 * ("Error ts(NNNN) ― "), matching the twoslash UI.
369 * - Works inside ```diff lang="typescript" blocks: a leading `+`/`-`/space
370 * diff marker before the comment is tolerated (the comment itself should
371 * be an unchanged/context line).
372 *
373 * The rendered markup and CSS replicate expressive-code-twoslash's error UI
374 * (`.twoslash-error-underline` squiggle + `.twoslash-error-box`), so the
375 * visual output is identical to what the compiler-backed plugin produced.
376 */
377export function errorAnnotations(): ExpressiveCodePlugin {
378 return {
379 name: "error-annotations",
380 baseStyles: `
381 .twoslash-error-underline {
382 text-decoration-line: spelling-error;
383 position: relative;
384 }
385 .twoslash-error-box {
386 display: flex;
387 z-index: 10;
388 padding: 0.1rem 0.3rem;
389 font-style: italic;
390 border: 1px solid rgba(from var(--al-error-col, #cd3131) r g b / 0.25);
391 border-radius: 4px;
392 font-size: 90%;
393 white-space: normal;
394 word-break: normal;
395 overflow-wrap: normal;
396 flex: 0 1 100%;
397 color: var(--al-error-col, #cd3131);
398 background: rgba(from var(--al-error-col, #cd3131) r g b / 0.1);
399 }
400 .twoslash-error-box .twoslash-error-box-icon {
401 display: inline-block;
402 vertical-align: middle;
403 }
404 .twoslash-error-box .twoslash-error-box-content {
405 display: inline-block;
406 vertical-align: middle;
407 flex: 0 1 100%;
408 }
409 .twoslash-error-box-content-message {
410 white-space: normal;
411 }
412 `,
413 hooks: {
414 preprocessCode({ codeBlock }) {
415 const lines = codeBlock.getLines();
416 /** @type {{ target: number, deletions: number[], parts: string[] }[]} */
417 const groups = [];
418 let current;
419 for (let i = 0; i < lines.length; i++) {
420 const match = lines[i].text.match(ERROR_COMMENT);
421 if (!match) {
422 current = undefined;
423 continue;
424 }
425 if (current) {
426 current.deletions.push(i);
427 current.parts.push(match[1]);
428 } else if (i > 0) {
429 current = { target: i - 1, deletions: [i], parts: [match[1]] };
430 groups.push(current);
431 }
432 }
433 if (groups.length === 0) return;
434
435 // Delete the comment lines (bottom-up so indices stay valid), then
436 // annotate each group's target line. Targets never move: a group's
437 // target always precedes its own deletions, and later groups'
438 // deletions never precede an earlier group's target.
439 const deleted = groups.flatMap((g) => g.deletions);
440 codeBlock.deleteLines(deleted);
441 for (const group of groups) {
442 const shift = deleted.filter((d) => d < group.target).length;
443 const line = codeBlock.getLine(group.target - shift);
444 if (!line) continue;
445 let message = group.parts.join("\n");
446 let title;
447 const code = message.match(TS_CODE);
448 if (code) {
449 title = `Error ts(${code[1]}) ― `;
450 message = message.slice(code[0].length);
451 }
452 line.addAnnotation(new ErrorUnderlineAnnotation({}));
453 line.addAnnotation(new ErrorBoxAnnotation(title, message));
454 }
455 },
456 },
457 };
458}