@elabs-ai/components-viewer 4.0.0

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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/chunk-2NQ4RSJ3.js +18 -0
  4. package/dist/chunk-2NQ4RSJ3.js.map +1 -0
  5. package/dist/chunk-3U3MESBU.js +21 -0
  6. package/dist/chunk-3U3MESBU.js.map +1 -0
  7. package/dist/chunk-4OFQYALG.js +147 -0
  8. package/dist/chunk-4OFQYALG.js.map +1 -0
  9. package/dist/chunk-5OMONA2G.js +87 -0
  10. package/dist/chunk-5OMONA2G.js.map +1 -0
  11. package/dist/chunk-AORNMH77.js +58 -0
  12. package/dist/chunk-AORNMH77.js.map +1 -0
  13. package/dist/chunk-BCF3RWRL.js +18 -0
  14. package/dist/chunk-BCF3RWRL.js.map +1 -0
  15. package/dist/chunk-FELIAGKI.js +22 -0
  16. package/dist/chunk-FELIAGKI.js.map +1 -0
  17. package/dist/chunk-GGR7BNW4.js +18 -0
  18. package/dist/chunk-GGR7BNW4.js.map +1 -0
  19. package/dist/chunk-H3OAON3D.js +22 -0
  20. package/dist/chunk-H3OAON3D.js.map +1 -0
  21. package/dist/chunk-HMG2ERXH.js +17 -0
  22. package/dist/chunk-HMG2ERXH.js.map +1 -0
  23. package/dist/chunk-KOMDRWOU.js +21 -0
  24. package/dist/chunk-KOMDRWOU.js.map +1 -0
  25. package/dist/chunk-NMA57QZ7.js +117 -0
  26. package/dist/chunk-NMA57QZ7.js.map +1 -0
  27. package/dist/chunk-RGWDIQJR.js +26 -0
  28. package/dist/chunk-RGWDIQJR.js.map +1 -0
  29. package/dist/chunk-SLXRUYT3.js +91 -0
  30. package/dist/chunk-SLXRUYT3.js.map +1 -0
  31. package/dist/chunk-UJIJECEF.js +21 -0
  32. package/dist/chunk-UJIJECEF.js.map +1 -0
  33. package/dist/chunk-UL43NGUG.js +68 -0
  34. package/dist/chunk-UL43NGUG.js.map +1 -0
  35. package/dist/chunk-VO2273Z2.js +55 -0
  36. package/dist/chunk-VO2273Z2.js.map +1 -0
  37. package/dist/code-adapter-ADZ4UOGN.js +226 -0
  38. package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
  39. package/dist/csv-adapter-6VU3FFVU.js +100 -0
  40. package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
  41. package/dist/docx-adapter-5CQDHWTD.js +383 -0
  42. package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
  43. package/dist/image-adapter-WOHZR24J.js +132 -0
  44. package/dist/image-adapter-WOHZR24J.js.map +1 -0
  45. package/dist/index.d.ts +1353 -0
  46. package/dist/index.js +1364 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/json-adapter-ZUW5GQHE.js +90 -0
  49. package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
  50. package/dist/markdown-adapter-YC6WTBS4.js +214 -0
  51. package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
  52. package/dist/media-adapter-MCTB4GBH.js +78 -0
  53. package/dist/media-adapter-MCTB4GBH.js.map +1 -0
  54. package/dist/pdf-adapter-5PMKEXUD.js +424 -0
  55. package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
  56. package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
  57. package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
  58. package/dist/text-adapter-NFNWB5W3.js +81 -0
  59. package/dist/text-adapter-NFNWB5W3.js.map +1 -0
  60. package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
  61. package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
  62. package/package.json +103 -0
  63. package/src/adapters/code/code-adapter.test.tsx +221 -0
  64. package/src/adapters/code/code-adapter.tsx +272 -0
  65. package/src/adapters/code/code-language.test.ts +45 -0
  66. package/src/adapters/code/code-language.ts +100 -0
  67. package/src/adapters/code/code-manifest.ts +19 -0
  68. package/src/adapters/code/code-theme.test.ts +46 -0
  69. package/src/adapters/code/code-theme.ts +99 -0
  70. package/src/adapters/csv/csv-adapter.test.tsx +169 -0
  71. package/src/adapters/csv/csv-adapter.tsx +146 -0
  72. package/src/adapters/csv/csv-manifest.ts +20 -0
  73. package/src/adapters/docx/docx-adapter.test.tsx +192 -0
  74. package/src/adapters/docx/docx-adapter.tsx +328 -0
  75. package/src/adapters/docx/docx-manifest.ts +18 -0
  76. package/src/adapters/docx/docx-model.test.ts +181 -0
  77. package/src/adapters/docx/docx-model.ts +294 -0
  78. package/src/adapters/image/image-adapter.test.tsx +141 -0
  79. package/src/adapters/image/image-adapter.tsx +191 -0
  80. package/src/adapters/image/image-manifest.ts +15 -0
  81. package/src/adapters/index.ts +64 -0
  82. package/src/adapters/json/json-adapter.test.tsx +77 -0
  83. package/src/adapters/json/json-adapter.tsx +141 -0
  84. package/src/adapters/json/json-manifest.ts +11 -0
  85. package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
  86. package/src/adapters/markdown/markdown-adapter.tsx +353 -0
  87. package/src/adapters/markdown/markdown-manifest.ts +21 -0
  88. package/src/adapters/markdown/markdown-marks.test.ts +85 -0
  89. package/src/adapters/markdown/markdown-marks.ts +119 -0
  90. package/src/adapters/media/media-adapter.test.tsx +79 -0
  91. package/src/adapters/media/media-adapter.tsx +123 -0
  92. package/src/adapters/media/media-manifest.ts +17 -0
  93. package/src/adapters/office-fixture.ts +239 -0
  94. package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
  95. package/src/adapters/pdf/pdf-adapter.tsx +599 -0
  96. package/src/adapters/pdf/pdf-engine.ts +171 -0
  97. package/src/adapters/pdf/pdf-fixture.ts +27 -0
  98. package/src/adapters/pdf/pdf-manifest.ts +30 -0
  99. package/src/adapters/pdf/pdf-text.test.ts +171 -0
  100. package/src/adapters/pdf/pdf-text.ts +202 -0
  101. package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
  102. package/src/adapters/pptx/pptx-adapter.tsx +341 -0
  103. package/src/adapters/pptx/pptx-manifest.ts +19 -0
  104. package/src/adapters/pptx/pptx-model.test.ts +219 -0
  105. package/src/adapters/pptx/pptx-model.ts +248 -0
  106. package/src/adapters/text/text-adapter.test.tsx +125 -0
  107. package/src/adapters/text/text-adapter.tsx +117 -0
  108. package/src/adapters/text/text-manifest.ts +19 -0
  109. package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
  110. package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
  111. package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
  112. package/src/components/grid-text.test.ts +68 -0
  113. package/src/components/grid-text.ts +77 -0
  114. package/src/components/marked-text.tsx +36 -0
  115. package/src/components/sheet-table.tsx +142 -0
  116. package/src/core/errors.ts +120 -0
  117. package/src/core/highlight-marks.test.ts +95 -0
  118. package/src/core/highlight-marks.ts +95 -0
  119. package/src/core/highlight-resolve.test.ts +208 -0
  120. package/src/core/highlight-resolve.ts +191 -0
  121. package/src/core/highlight.ts +125 -0
  122. package/src/core/registry.test.ts +172 -0
  123. package/src/core/registry.ts +188 -0
  124. package/src/core/scroll-host.ts +42 -0
  125. package/src/core/text-index.test.ts +95 -0
  126. package/src/core/text-index.ts +159 -0
  127. package/src/core/types.ts +285 -0
  128. package/src/core/use-highlight-scroll.ts +59 -0
  129. package/src/core/use-page-control.ts +47 -0
  130. package/src/core/use-paged-scroll.ts +194 -0
  131. package/src/core/use-viewport-size.ts +54 -0
  132. package/src/core/zoom.ts +56 -0
  133. package/src/file-viewer/file-viewer-context.tsx +239 -0
  134. package/src/file-viewer/file-viewer-find.tsx +204 -0
  135. package/src/file-viewer/file-viewer-pager.tsx +120 -0
  136. package/src/file-viewer/file-viewer-zoom.tsx +166 -0
  137. package/src/file-viewer/file-viewer.stories.tsx +947 -0
  138. package/src/file-viewer/file-viewer.test.tsx +947 -0
  139. package/src/file-viewer/file-viewer.tsx +1143 -0
  140. package/src/index.ts +157 -0
@@ -0,0 +1,272 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Code adapter — source files, highlighted with Shiki and coloured by tokens.
5
+ *
6
+ * Highlighting happens at LOAD, not at render: `load()` returns lines of plain
7
+ * `{ text, color }` tokens, so the renderer draws data like every other adapter
8
+ * and Shiki never reaches the render path. The colours are `var(--code-*)`
9
+ * references (see `code-theme.ts`), which is why a theme switch recolours the
10
+ * document without re-tokenizing it.
11
+ *
12
+ * The plain-text adapter is still the backstop. This one claims extensions it
13
+ * has a grammar for; anything else — a log, a `.env`, no extension at all —
14
+ * falls through to text, and so does every file when Shiki is not installed.
15
+ */
16
+
17
+ import { MatchHighlight, cn, StatePanel, useLocale } from "@elabs-ai/components-ui";
18
+ import type { ResolvedFileSource } from "@elabs-ai/components-ui";
19
+ import { Fragment, useMemo, useRef } from "react";
20
+ import type { BundledLanguage, ThemedToken } from "shiki";
21
+
22
+ import { toViewerError } from "../../core/errors";
23
+ import { localizeRanges, toMarkRanges } from "../../core/highlight-marks";
24
+ import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
25
+ import type {
26
+ AdapterDocument,
27
+ AdapterLoadContext,
28
+ AdapterModule,
29
+ AdapterRendererProps,
30
+ FileAdapter,
31
+ } from "../../core/types";
32
+ import { languageFor } from "./code-language";
33
+ import { codeManifest } from "./code-manifest";
34
+ import { codeViewerTheme } from "./code-theme";
35
+
36
+ /**
37
+ * Characters kept before truncation. Well below the plain-text limit because
38
+ * this text is also TOKENIZED — a grammar pass over multiple megabytes blocks
39
+ * the main thread, and no one reads a 400k-character file in a preview pane.
40
+ */
41
+ export const CODE_CHARACTER_LIMIT = 400_000;
42
+
43
+ /** One highlighted run. `color` is always a `var(--code-*)` reference. */
44
+ export interface CodeToken {
45
+ text: string;
46
+ color?: string;
47
+ italic?: boolean;
48
+ bold?: boolean;
49
+ underline?: boolean;
50
+ }
51
+
52
+ export interface CodeDocument extends AdapterDocument {
53
+ kind: "code";
54
+ /** Shiki language id, or `undefined` when the file rendered unhighlighted. */
55
+ language?: string;
56
+ /** One entry per line; a blank line is an empty array. */
57
+ lines: CodeToken[][];
58
+ text: string;
59
+ /** Total characters in the file, when more than what is shown. */
60
+ totalCharacters?: number;
61
+ }
62
+
63
+ // Shiki encodes font style as bitflags: 1 = italic, 2 = bold, 4 = underline.
64
+ const hasFontFlag = (fontStyle: number | undefined, flag: number) =>
65
+ ((fontStyle ?? 0) & flag) === flag;
66
+
67
+ /**
68
+ * Highlighters, cached per language for the lifetime of the page.
69
+ *
70
+ * Not per-document state, so it deliberately outlives the adapter instance:
71
+ * building one loads a grammar and the WASM engine, and paging through twenty
72
+ * TypeScript files in a file tree should pay for that once.
73
+ */
74
+ const highlighters = new Map<string, Promise<{ tokenize(code: string): ThemedToken[][] }>>();
75
+
76
+ function getHighlighter(language: BundledLanguage) {
77
+ let pending = highlighters.get(language);
78
+ if (!pending) {
79
+ pending = (async () => {
80
+ // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).
81
+ const { createHighlighter } = await import("shiki");
82
+ const highlighter = await createHighlighter({
83
+ themes: [codeViewerTheme],
84
+ langs: [language],
85
+ });
86
+ return {
87
+ tokenize: (code: string) =>
88
+ highlighter.codeToTokens(code, {
89
+ lang: language,
90
+ theme: codeViewerTheme.name as string,
91
+ }).tokens,
92
+ };
93
+ })().catch((error: unknown) => {
94
+ // Not cached: a peer installed later, or a transient chunk-load failure,
95
+ // must be retryable without a page reload.
96
+ highlighters.delete(language);
97
+ throw error;
98
+ });
99
+ highlighters.set(language, pending);
100
+ }
101
+ return pending;
102
+ }
103
+
104
+ /** Split unhighlighted text into the same line/token shape. */
105
+ function plainLines(text: string): CodeToken[][] {
106
+ return text.split("\n").map((line) => (line.length > 0 ? [{ text: line }] : []));
107
+ }
108
+
109
+ class CodeAdapter implements FileAdapter {
110
+ async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<CodeDocument> {
111
+ let raw: string;
112
+ try {
113
+ raw = await source.text(context.signal);
114
+ } catch (error) {
115
+ throw toViewerError(error, "read-failed", { fileName: source.name });
116
+ }
117
+
118
+ const truncated = raw.length > CODE_CHARACTER_LIMIT;
119
+ const text = truncated ? raw.slice(0, CODE_CHARACTER_LIMIT) : raw;
120
+ const totalCharacters = truncated ? raw.length : undefined;
121
+ // So a passage the shell cannot find reads as "past the part we previewed"
122
+ // rather than "not in this document".
123
+ const textTruncated = truncated || undefined;
124
+ const language = languageFor(source.name);
125
+
126
+ // No grammar for this name — still a perfectly readable file.
127
+ if (!language) {
128
+ return { kind: "code", lines: plainLines(text), text, totalCharacters, textTruncated };
129
+ }
130
+
131
+ const highlighter = await getHighlighter(language);
132
+ if (context.signal?.aborted) {
133
+ return {
134
+ kind: "code",
135
+ language,
136
+ lines: plainLines(text),
137
+ text,
138
+ totalCharacters,
139
+ textTruncated,
140
+ };
141
+ }
142
+
143
+ const lines = highlighter.tokenize(text).map((line) =>
144
+ line.map((token) => ({
145
+ text: token.content,
146
+ color: token.color,
147
+ italic: hasFontFlag(token.fontStyle, 1) || undefined,
148
+ bold: hasFontFlag(token.fontStyle, 2) || undefined,
149
+ underline: hasFontFlag(token.fontStyle, 4) || undefined,
150
+ })),
151
+ );
152
+
153
+ return { kind: "code", language, lines, text, totalCharacters, textTruncated };
154
+ }
155
+ }
156
+
157
+ /* -------------------------------------------------------------------------- */
158
+ /* Renderer */
159
+ /* -------------------------------------------------------------------------- */
160
+
161
+ /**
162
+ * Where each rendered line begins in `document.text`.
163
+ *
164
+ * Derived from the TEXT, not from the token lines: the text is the address space
165
+ * every highlight is expressed in, so counting its newlines is the only mapping
166
+ * that cannot drift from what a caller asked for. The two agree by construction
167
+ * — the tokenizer splits on the same newlines — and `code-adapter.test.tsx`
168
+ * locks that.
169
+ */
170
+ function lineStartsIn(text: string): number[] {
171
+ const starts = [0];
172
+ for (let i = 0; i < text.length; i++) {
173
+ if (text[i] === "\n") starts.push(i + 1);
174
+ }
175
+ return starts;
176
+ }
177
+
178
+ function CodeRenderer({
179
+ document: doc,
180
+ className,
181
+ highlights,
182
+ activeHighlightId,
183
+ }: AdapterRendererProps) {
184
+ const code = doc as CodeDocument;
185
+ const { t, formatNumber } = useLocale();
186
+ const container = useRef<HTMLDivElement>(null);
187
+ const lineStarts = useMemo(() => lineStartsIn(code.text), [code.text]);
188
+ const marks = useMemo(
189
+ () => toMarkRanges(highlights, code.text.length),
190
+ [highlights, code.text.length],
191
+ );
192
+ useScrollActiveHighlightIntoView(container, activeHighlightId);
193
+
194
+ if (code.text.length === 0) {
195
+ return <StatePanel kind="empty" title={t("viewer.file.empty")} className={className} />;
196
+ }
197
+
198
+ return (
199
+ // No `overflow-auto` here: `FileViewerContent` is the scroll boundary, and
200
+ // nesting a second one clips the last line above the outer pane's padding.
201
+ <div ref={container} className={cn("flex flex-col gap-2", className)}>
202
+ {code.totalCharacters !== undefined && (
203
+ // Not an error — the file is fine, we are showing part of it.
204
+ <p role="status" className="text-meta text-muted-foreground">
205
+ {t("viewer.text.truncated", {
206
+ shown: formatNumber(code.text.length),
207
+ total: formatNumber(code.totalCharacters),
208
+ })}
209
+ </p>
210
+ )}
211
+ <pre className="text-code text-code-foreground font-mono">
212
+ <code className="grid grid-cols-[auto_1fr] gap-x-4">
213
+ {code.lines.map((tokens, lineIndex) => {
214
+ let offset = lineStarts[lineIndex] ?? code.text.length;
215
+ return (
216
+ <Fragment key={lineIndex}>
217
+ {/* Decorative: the numbers are a reading aid, and keeping them out
218
+ of the accessibility tree also keeps them out of a copy. Not
219
+ `formatNumber` — a line gutter is an ordinal, so a thousands
220
+ separator at line 1,000 would be wrong in every locale. */}
221
+ <span
222
+ aria-hidden="true"
223
+ className="text-muted-foreground text-right tabular-nums select-none"
224
+ >
225
+ {lineIndex + 1}
226
+ </span>
227
+ <span className="break-words whitespace-pre-wrap">
228
+ {tokens.map((token, tokenIndex) => {
229
+ const start = offset;
230
+ offset += token.text.length;
231
+ // Syntax colour is inline `var(--code-*)` (see the module
232
+ // header), and a mark's plate would fight it — so the mark
233
+ // layer only wraps the tokens it actually touches, and every
234
+ // other token keeps rendering exactly as before.
235
+ const local = localizeRanges(marks, start, offset);
236
+ const style = {
237
+ color: token.color,
238
+ fontStyle: token.italic ? "italic" : undefined,
239
+ fontWeight: token.bold ? "bold" : undefined,
240
+ textDecoration: token.underline ? "underline" : undefined,
241
+ };
242
+ return local.ranges.length === 0 ? (
243
+ <span key={tokenIndex} style={style}>
244
+ {token.text}
245
+ </span>
246
+ ) : (
247
+ <MatchHighlight
248
+ key={tokenIndex}
249
+ style={style}
250
+ text={token.text}
251
+ ranges={local.ranges}
252
+ activeIndex={local.activeIndex}
253
+ />
254
+ );
255
+ })}
256
+ </span>
257
+ </Fragment>
258
+ );
259
+ })}
260
+ </code>
261
+ </pre>
262
+ </div>
263
+ );
264
+ }
265
+
266
+ const adapterModule: AdapterModule = {
267
+ manifest: codeManifest,
268
+ create: () => new CodeAdapter(),
269
+ Renderer: CodeRenderer,
270
+ };
271
+
272
+ export default adapterModule;
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { CODE_EXTENSIONS, languageFor } from "./code-language";
4
+
5
+ describe("languageFor", () => {
6
+ it("maps an extension to its Shiki grammar", () => {
7
+ expect(languageFor("registry.ts")).toBe("typescript");
8
+ expect(languageFor("Button.tsx")).toBe("tsx");
9
+ expect(languageFor("deploy.yml")).toBe("yaml");
10
+ });
11
+
12
+ it("is case-insensitive — a file name is not a grammar", () => {
13
+ expect(languageFor("MAIN.PY")).toBe("python");
14
+ });
15
+
16
+ it("reads the WHOLE name when a language carries it there", () => {
17
+ // Dockerfile and Makefile have no extension at all; matching only the last
18
+ // dot-segment would send both to the plain-text backstop.
19
+ expect(languageFor("Dockerfile")).toBe("docker");
20
+ expect(languageFor("Makefile")).toBe("make");
21
+ });
22
+
23
+ it("takes the LAST extension of a multi-part name", () => {
24
+ expect(languageFor("vite.config.ts")).toBe("typescript");
25
+ });
26
+
27
+ it("returns nothing for a file it has no grammar for", () => {
28
+ expect(languageFor("server.log")).toBeUndefined();
29
+ expect(languageFor("notes")).toBeUndefined();
30
+ });
31
+
32
+ it("leaves the formats other adapters own alone", () => {
33
+ // Claiming these would decide the winner by registration order rather than
34
+ // by design: JSON gets a tree, CSV a table, markdown a document.
35
+ for (const extension of ["json", "csv", "md", "markdown", "svg", "pdf"]) {
36
+ expect(CODE_EXTENSIONS).not.toContain(extension);
37
+ }
38
+ });
39
+
40
+ it("claims every extension it can actually highlight, and only those", () => {
41
+ for (const extension of CODE_EXTENSIONS) {
42
+ expect(languageFor(`file.${extension}`)).toBeTruthy();
43
+ }
44
+ });
45
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Extension → Shiki language id.
3
+ *
4
+ * A pure data module on purpose: `code-manifest.ts` derives the extensions it
5
+ * claims from this map, so the list the registry matches on and the list the
6
+ * highlighter can actually tokenize can never drift apart. Adding a language is
7
+ * one line here.
8
+ *
9
+ * What is deliberately ABSENT is as load-bearing as what is present. `json`,
10
+ * `csv`, `md` and `svg` are claimed by their own adapters — a JSON file gets a
11
+ * collapsible tree, not a coloured wall of text — and an extension listed in two
12
+ * manifests would be decided by registration order rather than by design.
13
+ */
14
+
15
+ import type { BundledLanguage } from "shiki";
16
+
17
+ /**
18
+ * Shiki's own language ids.
19
+ *
20
+ * `satisfies Record<string, BundledLanguage>` is the point of the annotation: a
21
+ * typo'd grammar name fails typecheck HERE, rather than at runtime as a file
22
+ * that silently refuses to highlight. Type-only import, so this stays a pure
23
+ * data module with no edge to the engine.
24
+ */
25
+ const LANGUAGE_BY_EXTENSION = {
26
+ bash: "shellscript",
27
+ c: "c",
28
+ cc: "cpp",
29
+ cjs: "javascript",
30
+ cpp: "cpp",
31
+ cs: "csharp",
32
+ css: "css",
33
+ cts: "typescript",
34
+ dart: "dart",
35
+ diff: "diff",
36
+ dockerfile: "docker",
37
+ go: "go",
38
+ gql: "graphql",
39
+ graphql: "graphql",
40
+ groovy: "groovy",
41
+ h: "c",
42
+ hcl: "hcl",
43
+ hpp: "cpp",
44
+ htm: "html",
45
+ html: "html",
46
+ ini: "ini",
47
+ java: "java",
48
+ js: "javascript",
49
+ jsx: "jsx",
50
+ kt: "kotlin",
51
+ kts: "kotlin",
52
+ less: "less",
53
+ lua: "lua",
54
+ makefile: "make",
55
+ mjs: "javascript",
56
+ mts: "typescript",
57
+ patch: "diff",
58
+ php: "php",
59
+ pl: "perl",
60
+ proto: "proto",
61
+ ps1: "powershell",
62
+ py: "python",
63
+ r: "r",
64
+ rb: "ruby",
65
+ rs: "rust",
66
+ sass: "sass",
67
+ scala: "scala",
68
+ scss: "scss",
69
+ sh: "shellscript",
70
+ sql: "sql",
71
+ svelte: "svelte",
72
+ swift: "swift",
73
+ tf: "terraform",
74
+ toml: "toml",
75
+ ts: "typescript",
76
+ tsx: "tsx",
77
+ vue: "vue",
78
+ xml: "xml",
79
+ yaml: "yaml",
80
+ yml: "yaml",
81
+ zsh: "shellscript",
82
+ } as const satisfies Record<string, BundledLanguage>;
83
+
84
+ /** Every extension the code adapter claims, lowercase and without the dot. */
85
+ export const CODE_EXTENSIONS = Object.keys(LANGUAGE_BY_EXTENSION);
86
+
87
+ /**
88
+ * The Shiki language for a file name, or `undefined` when nothing matches.
89
+ *
90
+ * Matches the extension first, then the whole (lowercased) file name — that is
91
+ * what catches `Dockerfile` and `Makefile`, which carry their language in the
92
+ * name and have no extension at all.
93
+ */
94
+ export function languageFor(fileName: string): BundledLanguage | undefined {
95
+ const name = fileName.toLowerCase();
96
+ const dot = name.lastIndexOf(".");
97
+ const extension = dot > 0 ? name.slice(dot + 1) : name;
98
+ const map: Record<string, BundledLanguage> = LANGUAGE_BY_EXTENSION;
99
+ return map[extension] ?? map[name];
100
+ }
@@ -0,0 +1,19 @@
1
+ import type { AdapterManifest } from "../../core/types";
2
+ import { PROTOCOL_VERSION } from "../../core/types";
3
+ import { CODE_EXTENSIONS } from "./code-language";
4
+
5
+ /**
6
+ * Eager, data-only (see `image-manifest.ts`).
7
+ *
8
+ * Claims EXTENSIONS only — never the `code` category. That is what keeps the
9
+ * plain-text adapter as the backstop: a `.log`, a `.env` or an unknown
10
+ * extension still opens as text instead of failing to find a grammar, and a
11
+ * consumer who never installs Shiki loses highlighting rather than the file.
12
+ */
13
+ export const codeManifest: AdapterManifest = {
14
+ id: "code",
15
+ protocol: PROTOCOL_VERSION,
16
+ extensions: CODE_EXTENSIONS,
17
+ capabilities: { text: true, search: true, highlight: ["quote", "range"] },
18
+ requires: ["shiki"],
19
+ };
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { codeViewerTheme } from "./code-theme";
4
+
5
+ const rules = codeViewerTheme.settings ?? [];
6
+
7
+ /** Every rule whose scope list contains `scope`, whatever else it contains. */
8
+ const rulesFor = (scope: string) =>
9
+ rules.filter((rule) => {
10
+ const declared = rule.scope;
11
+ const list = Array.isArray(declared) ? declared : declared ? [declared] : [];
12
+ return list.includes(scope);
13
+ });
14
+
15
+ describe("code viewer theme", () => {
16
+ it("colours every scope with a token, never a literal", () => {
17
+ // The whole reason ONE theme is correct in every brand theme. A hex here
18
+ // would freeze at tokenize time and survive a theme switch unchanged.
19
+ for (const rule of rules) {
20
+ expect(rule.settings?.foreground).toMatch(/^var\(--code-[a-z-]+\)$/);
21
+ }
22
+ expect(codeViewerTheme.bg).toBe("transparent");
23
+ });
24
+
25
+ it("italicises comments and attribute names — and nothing else", () => {
26
+ const italic = rules.filter((rule) => rule.settings?.fontStyle === "italic");
27
+ const scopes = italic.flatMap((rule) =>
28
+ Array.isArray(rule.scope) ? rule.scope : rule.scope ? [rule.scope] : [],
29
+ );
30
+ expect(scopes).toContain("comment");
31
+ expect(scopes).toContain("entity.other.attribute-name");
32
+ expect(scopes).not.toContain("variable");
33
+ expect(scopes).not.toContain("meta.object-literal.key");
34
+ });
35
+
36
+ it("keeps plain identifiers upright", () => {
37
+ // The regression this locks: `entity.other.attribute-name` shared an array
38
+ // with `variable`, and a rule's `fontStyle` applies to its whole scope list
39
+ // — so every local, parameter and imported binding in the file leaned.
40
+ // Asserting on the rule (not on rendered output) is the point: the bug was
41
+ // invisible in any fixture without attributes.
42
+ for (const rule of rulesFor("variable")) {
43
+ expect(rule.settings?.fontStyle).toBeUndefined();
44
+ }
45
+ });
46
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The Shiki theme the code adapter tokenizes with — brand tokens, not a
3
+ * recoloured `github-light`.
4
+ *
5
+ * Every colour here is a `var(--code-*)` reference rather than a literal, which
6
+ * is what makes ONE theme correct in EVERY brand theme: the active `data-theme`
7
+ * block decides what `--code-keyword` resolves to, so a runtime theme switch
8
+ * recolours already-tokenized code with no re-highlight, no `MutationObserver`
9
+ * and no cache to invalidate. It is the same technique Shiki ships as
10
+ * `createCssVariablesTheme`, written out by hand for one reason: that helper
11
+ * maps a fixed, smaller scope set and cannot reach `--code-type` or
12
+ * `--code-tag`, which this design system does define.
13
+ *
14
+ * `@elabs-ai/components-ai`'s `CodeBlock` resolves the same tokens
15
+ * the other way round (oklch → hex, re-derived per theme change) because
16
+ * `@streamdown/code` freezes its themes at import time and cannot take a live
17
+ * `var()`. Nothing here needs that, so nothing here pays for it.
18
+ *
19
+ * Type-only import: this module is pure data and holds no edge to the engine.
20
+ */
21
+
22
+ import type { ThemeRegistrationRaw } from "shiki";
23
+
24
+ /**
25
+ * TextMate scopes → brand code tokens.
26
+ *
27
+ * Ordered coarse-to-fine, because Shiki resolves the LAST matching rule: the
28
+ * broad `constant` rule has to come before `constant.numeric` or every number
29
+ * would read as a constant.
30
+ *
31
+ * `italic` is a convention, not a colour, so it is theme-safe — but it is
32
+ * declared PER RULE and never inferred from the scope list. A rule's styling
33
+ * applies to every scope in its array, so a lone italic scope sharing an array
34
+ * with ordinary ones italicises all of them: bundling `entity.other.attribute-name`
35
+ * with `variable` slanted every plain identifier in the file, which is the most
36
+ * common token class there is. Give an italic scope its own entry.
37
+ */
38
+ const SCOPE_COLORS: ReadonlyArray<{
39
+ readonly scopes: string[];
40
+ readonly token: string;
41
+ readonly italic?: boolean;
42
+ }> = [
43
+ {
44
+ scopes: ["comment", "punctuation.definition.comment"],
45
+ token: "--code-comment",
46
+ italic: true,
47
+ },
48
+ { scopes: ["string", "string.quoted", "punctuation.definition.string"], token: "--code-string" },
49
+ {
50
+ scopes: ["constant", "constant.language", "constant.character", "support.constant"],
51
+ token: "--code-constant",
52
+ },
53
+ { scopes: ["constant.numeric", "keyword.other.unit"], token: "--code-number" },
54
+ {
55
+ scopes: ["keyword", "keyword.control", "keyword.operator", "storage", "storage.type"],
56
+ token: "--code-keyword",
57
+ },
58
+ {
59
+ scopes: ["entity.name.function", "support.function", "meta.function-call"],
60
+ token: "--code-function",
61
+ },
62
+ {
63
+ scopes: ["entity.name.type", "entity.name.class", "support.type", "support.class"],
64
+ token: "--code-type",
65
+ },
66
+ { scopes: ["entity.name.tag", "punctuation.definition.tag"], token: "--code-tag" },
67
+ { scopes: ["variable", "support.variable", "meta.object-literal.key"], token: "--code-property" },
68
+ // Same colour as a property, but the one other scope that leans. Its own rule,
69
+ // for the reason in the docblock above.
70
+ { scopes: ["entity.other.attribute-name"], token: "--code-property", italic: true },
71
+ ];
72
+
73
+ /**
74
+ * The theme, built once at module load. Shiki treats a theme object as opaque
75
+ * data, so one instance is safely shared by every highlighter.
76
+ */
77
+ export const codeViewerTheme: ThemeRegistrationRaw = {
78
+ name: "brand-ui-viewer",
79
+ // Shiki uses `type` only to pick its own light/dark defaults, which this theme
80
+ // overrides wholesale. It is `css` — neither, resolved at paint time.
81
+ type: "dark",
82
+ colors: {
83
+ // The renderer owns the surface; the theme must not paint over it.
84
+ "editor.background": "transparent",
85
+ "editor.foreground": "var(--code-foreground)",
86
+ },
87
+ fg: "var(--code-foreground)",
88
+ bg: "transparent",
89
+ settings: [
90
+ { settings: { foreground: "var(--code-foreground)" } },
91
+ ...SCOPE_COLORS.map(({ scopes, token, italic }) => ({
92
+ scope: scopes,
93
+ settings: {
94
+ foreground: `var(${token})`,
95
+ ...(italic ? { fontStyle: "italic" } : undefined),
96
+ },
97
+ })),
98
+ ],
99
+ };