@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/chunk-2NQ4RSJ3.js +18 -0
- package/dist/chunk-2NQ4RSJ3.js.map +1 -0
- package/dist/chunk-3U3MESBU.js +21 -0
- package/dist/chunk-3U3MESBU.js.map +1 -0
- package/dist/chunk-4OFQYALG.js +147 -0
- package/dist/chunk-4OFQYALG.js.map +1 -0
- package/dist/chunk-5OMONA2G.js +87 -0
- package/dist/chunk-5OMONA2G.js.map +1 -0
- package/dist/chunk-AORNMH77.js +58 -0
- package/dist/chunk-AORNMH77.js.map +1 -0
- package/dist/chunk-BCF3RWRL.js +18 -0
- package/dist/chunk-BCF3RWRL.js.map +1 -0
- package/dist/chunk-FELIAGKI.js +22 -0
- package/dist/chunk-FELIAGKI.js.map +1 -0
- package/dist/chunk-GGR7BNW4.js +18 -0
- package/dist/chunk-GGR7BNW4.js.map +1 -0
- package/dist/chunk-H3OAON3D.js +22 -0
- package/dist/chunk-H3OAON3D.js.map +1 -0
- package/dist/chunk-HMG2ERXH.js +17 -0
- package/dist/chunk-HMG2ERXH.js.map +1 -0
- package/dist/chunk-KOMDRWOU.js +21 -0
- package/dist/chunk-KOMDRWOU.js.map +1 -0
- package/dist/chunk-NMA57QZ7.js +117 -0
- package/dist/chunk-NMA57QZ7.js.map +1 -0
- package/dist/chunk-RGWDIQJR.js +26 -0
- package/dist/chunk-RGWDIQJR.js.map +1 -0
- package/dist/chunk-SLXRUYT3.js +91 -0
- package/dist/chunk-SLXRUYT3.js.map +1 -0
- package/dist/chunk-UJIJECEF.js +21 -0
- package/dist/chunk-UJIJECEF.js.map +1 -0
- package/dist/chunk-UL43NGUG.js +68 -0
- package/dist/chunk-UL43NGUG.js.map +1 -0
- package/dist/chunk-VO2273Z2.js +55 -0
- package/dist/chunk-VO2273Z2.js.map +1 -0
- package/dist/code-adapter-ADZ4UOGN.js +226 -0
- package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
- package/dist/csv-adapter-6VU3FFVU.js +100 -0
- package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
- package/dist/docx-adapter-5CQDHWTD.js +383 -0
- package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
- package/dist/image-adapter-WOHZR24J.js +132 -0
- package/dist/image-adapter-WOHZR24J.js.map +1 -0
- package/dist/index.d.ts +1353 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-ZUW5GQHE.js +90 -0
- package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
- package/dist/markdown-adapter-YC6WTBS4.js +214 -0
- package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
- package/dist/media-adapter-MCTB4GBH.js +78 -0
- package/dist/media-adapter-MCTB4GBH.js.map +1 -0
- package/dist/pdf-adapter-5PMKEXUD.js +424 -0
- package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
- package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
- package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
- package/dist/text-adapter-NFNWB5W3.js +81 -0
- package/dist/text-adapter-NFNWB5W3.js.map +1 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
- package/package.json +103 -0
- package/src/adapters/code/code-adapter.test.tsx +221 -0
- package/src/adapters/code/code-adapter.tsx +272 -0
- package/src/adapters/code/code-language.test.ts +45 -0
- package/src/adapters/code/code-language.ts +100 -0
- package/src/adapters/code/code-manifest.ts +19 -0
- package/src/adapters/code/code-theme.test.ts +46 -0
- package/src/adapters/code/code-theme.ts +99 -0
- package/src/adapters/csv/csv-adapter.test.tsx +169 -0
- package/src/adapters/csv/csv-adapter.tsx +146 -0
- package/src/adapters/csv/csv-manifest.ts +20 -0
- package/src/adapters/docx/docx-adapter.test.tsx +192 -0
- package/src/adapters/docx/docx-adapter.tsx +328 -0
- package/src/adapters/docx/docx-manifest.ts +18 -0
- package/src/adapters/docx/docx-model.test.ts +181 -0
- package/src/adapters/docx/docx-model.ts +294 -0
- package/src/adapters/image/image-adapter.test.tsx +141 -0
- package/src/adapters/image/image-adapter.tsx +191 -0
- package/src/adapters/image/image-manifest.ts +15 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/json/json-adapter.test.tsx +77 -0
- package/src/adapters/json/json-adapter.tsx +141 -0
- package/src/adapters/json/json-manifest.ts +11 -0
- package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
- package/src/adapters/markdown/markdown-adapter.tsx +353 -0
- package/src/adapters/markdown/markdown-manifest.ts +21 -0
- package/src/adapters/markdown/markdown-marks.test.ts +85 -0
- package/src/adapters/markdown/markdown-marks.ts +119 -0
- package/src/adapters/media/media-adapter.test.tsx +79 -0
- package/src/adapters/media/media-adapter.tsx +123 -0
- package/src/adapters/media/media-manifest.ts +17 -0
- package/src/adapters/office-fixture.ts +239 -0
- package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
- package/src/adapters/pdf/pdf-adapter.tsx +599 -0
- package/src/adapters/pdf/pdf-engine.ts +171 -0
- package/src/adapters/pdf/pdf-fixture.ts +27 -0
- package/src/adapters/pdf/pdf-manifest.ts +30 -0
- package/src/adapters/pdf/pdf-text.test.ts +171 -0
- package/src/adapters/pdf/pdf-text.ts +202 -0
- package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
- package/src/adapters/pptx/pptx-adapter.tsx +341 -0
- package/src/adapters/pptx/pptx-manifest.ts +19 -0
- package/src/adapters/pptx/pptx-model.test.ts +219 -0
- package/src/adapters/pptx/pptx-model.ts +248 -0
- package/src/adapters/text/text-adapter.test.tsx +125 -0
- package/src/adapters/text/text-adapter.tsx +117 -0
- package/src/adapters/text/text-manifest.ts +19 -0
- package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
- package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
- package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
- package/src/components/grid-text.test.ts +68 -0
- package/src/components/grid-text.ts +77 -0
- package/src/components/marked-text.tsx +36 -0
- package/src/components/sheet-table.tsx +142 -0
- package/src/core/errors.ts +120 -0
- package/src/core/highlight-marks.test.ts +95 -0
- package/src/core/highlight-marks.ts +95 -0
- package/src/core/highlight-resolve.test.ts +208 -0
- package/src/core/highlight-resolve.ts +191 -0
- package/src/core/highlight.ts +125 -0
- package/src/core/registry.test.ts +172 -0
- package/src/core/registry.ts +188 -0
- package/src/core/scroll-host.ts +42 -0
- package/src/core/text-index.test.ts +95 -0
- package/src/core/text-index.ts +159 -0
- package/src/core/types.ts +285 -0
- package/src/core/use-highlight-scroll.ts +59 -0
- package/src/core/use-page-control.ts +47 -0
- package/src/core/use-paged-scroll.ts +194 -0
- package/src/core/use-viewport-size.ts +54 -0
- package/src/core/zoom.ts +56 -0
- package/src/file-viewer/file-viewer-context.tsx +239 -0
- package/src/file-viewer/file-viewer-find.tsx +204 -0
- package/src/file-viewer/file-viewer-pager.tsx +120 -0
- package/src/file-viewer/file-viewer-zoom.tsx +166 -0
- package/src/file-viewer/file-viewer.stories.tsx +947 -0
- package/src/file-viewer/file-viewer.test.tsx +947 -0
- package/src/file-viewer/file-viewer.tsx +1143 -0
- 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
|
+
};
|