Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 151 additions & 19 deletions apps/web/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import {
} from '../runtime/exports';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import type { HighlightedCodeToken } from '../runtime/shiki';
import { shouldConsumeSlideNav } from '../runtime/slide-nav';
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
import {
Expand Down Expand Up @@ -699,6 +700,140 @@ async function highlightMarkdownCodeBlocks(html: string): Promise<string> {
return changed ? root.innerHTML : html;
}

const MAX_HIGHLIGHT_SOURCE_CHARS = 100_000;
const SYNTAX_LANGUAGE_BY_EXTENSION: Record<string, string> = {
cjs: 'javascript',
css: 'css',
cts: 'typescript',
htm: 'html',
html: 'html',
js: 'javascript',
jsx: 'jsx',
json: 'json',
mjs: 'javascript',
mts: 'typescript',
svg: 'xml',
ts: 'typescript',
tsx: 'tsx',
xhtml: 'html',
};
const SYNTAX_LANGUAGE_BY_MIME: Record<string, string> = {
'application/ecmascript': 'javascript',
'application/javascript': 'javascript',
'application/json': 'json',
'application/typescript': 'typescript',
'application/xhtml+xml': 'html',
'image/svg+xml': 'xml',
'text/css': 'css',
'text/html': 'html',
'text/javascript': 'javascript',
'text/jsx': 'jsx',
'text/json': 'json',
'text/tsx': 'tsx',
'text/typescript': 'typescript',
};

export function syntaxLanguageForFile(
file: Pick<ProjectFile, 'name' | 'mime'>,
): string | null {
const extension = file.name.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? '';
if (extension && SYNTAX_LANGUAGE_BY_EXTENSION[extension]) {
return SYNTAX_LANGUAGE_BY_EXTENSION[extension];
}
const mime = file.mime.toLowerCase().split(';', 1)[0]?.trim() ?? '';
return SYNTAX_LANGUAGE_BY_MIME[mime] ?? null;
}

function useHighlightThemeRevision(): number {
const [revision, setRevision] = useState(0);
useEffect(() => {
const bump = () => setRevision((current) => current + 1);
const observer = new MutationObserver(bump);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
media?.addEventListener('change', bump);
return () => {
observer.disconnect();
media?.removeEventListener('change', bump);
};
}, []);
return revision;
}

function highlightedCodeText(lines: HighlightedCodeToken[][]): string {
return lines
.map((line) => line.map((token) => token.content).join(''))
.join('\n');
}

function HighlightedCode({
text,
language,
}: {
text: string;
language: string | null;
}) {
const themeRevision = useHighlightThemeRevision();
const [highlighted, setHighlighted] = useState<{
text: string;
language: string;
themeRevision: number;
lines: HighlightedCodeToken[][];
} | null>(null);

useEffect(() => {
if (!language || text.length > MAX_HIGHLIGHT_SOURCE_CHARS) {
setHighlighted(null);
return undefined;
}
let cancelled = false;
void import('../runtime/shiki')
.then(({ highlightCodeTokens }) => highlightCodeTokens(text, language))
.then((lines) => {
if (cancelled) return;
if (lines.length === 0 || highlightedCodeText(lines) !== text.replace(/\r\n/g, '\n')) {
Comment thread
Nameless-Monster-Nerd marked this conversation as resolved.
Outdated
setHighlighted(null);
return;
}
setHighlighted({ text, language, themeRevision, lines });
})
.catch(() => {
if (!cancelled) setHighlighted(null);
});
return () => {
cancelled = true;
};
}, [language, text, themeRevision]);

const lines =
highlighted?.text === text &&
highlighted.language === language &&
highlighted.themeRevision === themeRevision
? highlighted.lines
: null;
if (!lines) return text;

const nodes: ReactNode[] = [];
const lineBreaks = text.match(/\r\n|\n/g) ?? [];
lines.forEach((line, lineIndex) => {
line.forEach((token, tokenIndex) => {
nodes.push(
<span
key={`${lineIndex}:${tokenIndex}`}
style={token.color ? { color: token.color } : undefined}
>
{token.content}
</span>,
);
});
if (lineIndex < lines.length - 1) nodes.push(lineBreaks[lineIndex] ?? '\n');
});
return nodes;
}

function rewriteMarkdownImageSources(html: string, projectId: string, markdownPath: string): string {
return html.replace(/<img\b([^>]*?)\bsrc="([^"]*)"([^>]*)>/g, (match, before: string, src: string, after: string) => {
const resolved = markdownImageSourceUrl(projectId, markdownPath, decodeHtmlAttribute(src));
Expand Down Expand Up @@ -5863,7 +5998,7 @@ function ReactComponentViewer({
/>
</PreviewDrawOverlay>
) : (
<CodeWithLines text={source} />
<CodeWithLines text={source} language={syntaxLanguageForFile(file)} />
)}
</div>
</div>
Expand Down Expand Up @@ -13757,7 +13892,9 @@ function HtmlViewer({
) : null}
</div>
) : (
<pre className="viewer-source">{source}</pre>
<pre className="viewer-source">
<HighlightedCode text={source ?? ''} language={syntaxLanguageForFile(file)} />
</pre>
)}
</div>
{speakerNotesPanel}
Expand Down Expand Up @@ -14983,7 +15120,7 @@ function TextViewer({
{text === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : displayText !== null && lineCount > 0 ? (
<CodeWithLines text={displayText} />
<CodeWithLines text={displayText} language={syntaxLanguageForFile(file)} />
) : (
<pre className="viewer-source">{displayText}</pre>
)}
Expand Down Expand Up @@ -15141,7 +15278,7 @@ function MarkdownViewer({
const [saveState, setSaveState] = useState<MarkdownSaveState>('idle');
const [savedAt, setSavedAt] = useState<number | null>(null);
const [highlightedHtml, setHighlightedHtml] = useState<{ source: string; html: string; themeRevision: number } | null>(null);
const [highlightThemeRevision, setHighlightThemeRevision] = useState(0);
const highlightThemeRevision = useHighlightThemeRevision();
const [, bumpSavedRevision] = useState(0);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const markdownPreviewPaneRef = useRef<HTMLElement | null>(null);
Expand Down Expand Up @@ -15331,19 +15468,6 @@ function MarkdownViewer({
};
}, [saveMarkdownText, text]);

useEffect(() => {
const root = document.documentElement;
const bump = () => setHighlightThemeRevision((revision) => revision + 1);
const observer = new MutationObserver(bump);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
media?.addEventListener('change', bump);
return () => {
observer.disconnect();
media?.removeEventListener('change', bump);
};
}, []);

async function copy() {
if (text == null) return;
const didCopy = await copyTextToClipboard(text);
Expand Down Expand Up @@ -15802,7 +15926,13 @@ function markdownImageAlt(name: string): string {
.trim() || 'image';
}

function CodeWithLines({ text }: { text: string }) {
function CodeWithLines({
text,
language = null,
}: {
text: string;
language?: string | null;
}) {
const lines = text.split('\n');
// Trailing newline produces a phantom empty line — keep gutter aligned.
const gutter = lines.map((_, i) => `${i + 1}`).join('\n');
Expand All @@ -15811,7 +15941,9 @@ function CodeWithLines({ text }: { text: string }) {
<code className="gutter" aria-hidden>
{gutter}
</code>
<code className="lines">{text}</code>
<code className="lines">
<HighlightedCode text={text} language={language} />
</code>
</pre>
);
}
Expand Down
95 changes: 83 additions & 12 deletions apps/web/src/runtime/shiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,25 @@ import type { HighlighterGeneric } from 'shiki';
let highlighterPromise: Promise<HighlighterGeneric<any, any>> | null = null;

const cache = new Map<string, string>();
const tokenCache = new Map<string, HighlightedCodeToken[][]>();
const languageLoadPromises = new Map<string, Promise<void>>();
const CACHE_MAX = 128;
const TOKEN_CACHE_MAX = 16;
const TOKEN_RENDER_MAX = 20_000;
const LAZY_LANGUAGE_LOADERS = {
diff: () => import('shiki/langs/diff.mjs').then((module) => module.default),
dockerfile: () => import('shiki/langs/dockerfile.mjs').then((module) => module.default),
go: () => import('shiki/langs/go.mjs').then((module) => module.default),
ruby: () => import('shiki/langs/ruby.mjs').then((module) => module.default),
rust: () => import('shiki/langs/rust.mjs').then((module) => module.default),
swift: () => import('shiki/langs/swift.mjs').then((module) => module.default),
toml: () => import('shiki/langs/toml.mjs').then((module) => module.default),
};

export type HighlightedCodeToken = {
content: string;
color?: string;
};

function getHighlighter(): Promise<HighlighterGeneric<any, any>> {
if (!highlighterPromise) {
Expand All @@ -12,9 +30,8 @@ function getHighlighter(): Promise<HighlighterGeneric<any, any>> {
themes: ['github-light-default', 'github-dark-default'],
langs: [
'javascript', 'typescript', 'tsx', 'jsx', 'html', 'css', 'json',
'python', 'bash', 'shell', 'markdown', 'yaml', 'sql', 'rust',
'go', 'java', 'c', 'cpp', 'swift', 'ruby', 'php', 'diff',
'toml', 'xml', 'graphql', 'dockerfile',
'python', 'bash', 'shell', 'markdown', 'yaml', 'sql', 'java',
'c', 'cpp', 'php', 'xml', 'graphql',
],
}),
);
Expand All @@ -30,27 +47,81 @@ function isDarkMode(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}

function setBoundedCache<T>(
target: Map<string, T>,
key: string,
value: T,
maxEntries = CACHE_MAX,
): void {
if (target.size >= maxEntries) {
const first = target.keys().next().value;
if (first !== undefined) target.delete(first);
}
target.set(key, value);
}

async function ensureLanguageLoaded(
highlighter: HighlighterGeneric<any, any>,
lang: string,
): Promise<boolean> {
if (highlighter.getLoadedLanguages().includes(lang as any)) return true;
const loader = LAZY_LANGUAGE_LOADERS[lang as keyof typeof LAZY_LANGUAGE_LOADERS];
if (!loader) return false;

let loadPromise = languageLoadPromises.get(lang);
if (!loadPromise) {
loadPromise = loader().then((registrations) => highlighter.loadLanguage(...registrations));
languageLoadPromises.set(lang, loadPromise);
}
try {
await loadPromise;
} catch (error) {
languageLoadPromises.delete(lang);
throw error;
}
return highlighter.getLoadedLanguages().includes(lang as any);
}

export async function highlightCode(code: string, lang: string): Promise<string> {
const dark = isDarkMode();
const cacheKey = `${dark ? 'd' : 'l'}:${lang}:${code}`;
const cached = cache.get(cacheKey);
if (cached) return cached;

const highlighter = await getHighlighter();
const loadedLangs = highlighter.getLoadedLanguages();
if (!loadedLangs.includes(lang as any)) {
return '';
}
if (!(await ensureLanguageLoaded(highlighter, lang))) return '';

const html = highlighter.codeToHtml(code, {
lang,
theme: dark ? 'github-dark-default' : 'github-light-default',
});

if (cache.size >= CACHE_MAX) {
const first = cache.keys().next().value;
if (first !== undefined) cache.delete(first);
}
cache.set(cacheKey, html);
setBoundedCache(cache, cacheKey, html);
return html;
}

export async function highlightCodeTokens(
code: string,
lang: string,
): Promise<HighlightedCodeToken[][]> {
const dark = isDarkMode();
const cacheKey = `${dark ? 'd' : 'l'}:${lang}:${code}`;
const cached = tokenCache.get(cacheKey);
if (cached) return cached;

const highlighter = await getHighlighter();
if (!(await ensureLanguageLoaded(highlighter, lang))) return [];

const result = highlighter.codeToTokens(code, {
lang,
theme: dark ? 'github-dark-default' : 'github-light-default',
});
const tokens = result.tokens.map((line) =>
line.map(({ content, color }) => ({ content, color })),
);
const tokenCount = tokens.reduce((count, line) => count + line.length, 0);
if (tokenCount > TOKEN_RENDER_MAX) return [];

setBoundedCache(tokenCache, cacheKey, tokens, TOKEN_CACHE_MAX);
return tokens;
}
Loading
Loading