feat: add syntax highlighting for R, Ruby, PHP, and SQL

Cherry-picked from upstream 022c75da with conflict resolution.
Merged our codeClassName support with upstream per-line highlighting.
This commit is contained in:
iliya 2026-03-25 14:05:53 +02:00
parent b87082a915
commit aeed5a0873
2 changed files with 224 additions and 4 deletions

View file

@ -39,6 +39,7 @@ import {
highlightSearchInChildren,
type SearchContext,
} from '../searchHighlightUtils';
import { highlightLine } from '../viewers/syntaxHighlighter';
import { FileLink, isRelativeUrl } from './FileLink';
import { MermaidDiagram } from './MermaidDiagram';
@ -535,12 +536,21 @@ function createViewerMarkdownComponents(
const isBlock = (hasLanguage ?? false) || isMultiLine;
if (isBlock) {
const lang = codeClassName?.replace('language-', '') ?? '';
const raw = typeof children === 'string' ? children : '';
const text = raw.replace(/\n$/, '');
const lines = text.split('\n');
return (
<code
className={`font-mono text-xs ${codeClassName ?? ''}`.trim()}
style={{ color: COLOR_TEXT }}
>
{hl(children)}
{lines.map((line, i) => (
<React.Fragment key={i}>
{hl(highlightLine(line, lang))}
{i < lines.length - 1 ? '\n' : null}
</React.Fragment>
))}
</code>
);
}

View file

@ -208,6 +208,200 @@ const KEYWORDS: Record<string, Set<string>> = {
'true',
'false',
]),
r: new Set([
'if',
'else',
'for',
'while',
'repeat',
'function',
'return',
'next',
'break',
'in',
'library',
'require',
'source',
'TRUE',
'FALSE',
'NULL',
'NA',
'Inf',
'NaN',
'NA_integer_',
'NA_real_',
'NA_complex_',
'NA_character_',
]),
ruby: new Set([
'def',
'class',
'module',
'end',
'do',
'if',
'elsif',
'else',
'unless',
'while',
'until',
'for',
'in',
'begin',
'rescue',
'ensure',
'raise',
'return',
'yield',
'block_given?',
'require',
'require_relative',
'include',
'extend',
'attr_accessor',
'attr_reader',
'attr_writer',
'self',
'super',
'nil',
'true',
'false',
'and',
'or',
'not',
'then',
'when',
'case',
'lambda',
'proc',
'puts',
'print',
]),
php: new Set([
'function',
'class',
'interface',
'trait',
'extends',
'implements',
'namespace',
'use',
'public',
'private',
'protected',
'static',
'abstract',
'final',
'const',
'var',
'new',
'return',
'if',
'elseif',
'else',
'for',
'foreach',
'while',
'do',
'switch',
'case',
'break',
'continue',
'default',
'try',
'catch',
'finally',
'throw',
'as',
'echo',
'print',
'require',
'require_once',
'include',
'include_once',
'true',
'false',
'null',
'array',
'isset',
'unset',
'empty',
'self',
'this',
]),
sql: new Set([
'SELECT',
'FROM',
'WHERE',
'INSERT',
'INTO',
'UPDATE',
'SET',
'DELETE',
'CREATE',
'ALTER',
'DROP',
'TABLE',
'INDEX',
'VIEW',
'DATABASE',
'JOIN',
'INNER',
'LEFT',
'RIGHT',
'OUTER',
'FULL',
'CROSS',
'ON',
'AND',
'OR',
'NOT',
'IN',
'EXISTS',
'BETWEEN',
'LIKE',
'IS',
'NULL',
'AS',
'ORDER',
'BY',
'GROUP',
'HAVING',
'LIMIT',
'OFFSET',
'UNION',
'ALL',
'DISTINCT',
'COUNT',
'SUM',
'AVG',
'MIN',
'MAX',
'CASE',
'WHEN',
'THEN',
'ELSE',
'END',
'BEGIN',
'COMMIT',
'ROLLBACK',
'TRANSACTION',
'PRIMARY',
'KEY',
'FOREIGN',
'REFERENCES',
'CONSTRAINT',
'DEFAULT',
'VALUES',
'TRUE',
'FALSE',
'INTEGER',
'VARCHAR',
'TEXT',
'BOOLEAN',
'DATE',
'TIMESTAMP',
]),
};
// Extend tsx/jsx to use typescript/javascript keywords
@ -296,8 +490,23 @@ export function highlightLine(line: string, language: string): React.ReactNode[]
break;
}
// Check for comment (# style for Python/Shell)
if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) {
// Check for comment (# style for Python/Shell/R/Ruby/PHP)
if (
(language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') &&
remaining.startsWith('#')
) {
segments.push(
React.createElement(
'span',
{ key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } },
remaining
)
);
break;
}
// Check for comment (-- style for SQL)
if (language === 'sql' && remaining.startsWith('--')) {
segments.push(
React.createElement(
'span',
@ -326,7 +535,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[]
const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining);
if (wordMatch) {
const word = wordMatch[1];
if (keywords.has(word)) {
// SQL keywords are case-insensitive
if (keywords.has(word) || (language === 'sql' && keywords.has(word.toUpperCase()))) {
segments.push(
React.createElement(
'span',