| 1 |
<?php |
| 2 |
/** |
| 3 |
* Small, regex-based syntax highlighter. Deliberately "basic" — it is not a |
| 4 |
* full lexer. Keyword-based languages (php, js, python, java, c, cpp, go, ruby, |
| 5 |
* sql, bash) share one master-regex builder; the structural formats css, html, |
| 6 |
* json and markdown get tailored regexes. Everything else falls back to plain |
| 7 |
* (escape-only) output. |
| 8 |
* |
| 9 |
* The source is scanned with a single master regex whose alternatives are named |
| 10 |
* groups (comment / string / number / variable / keyword / function / tag / |
| 11 |
* attribute / selector). Each character is consumed once, so tokens are never |
| 12 |
* re-processed and output is always HTML-escaped exactly once. |
| 13 |
* |
| 14 |
* Note: the file view highlights one line at a time, so patterns are kept |
| 15 |
* single-line friendly (block comments only colour within a line). |
| 16 |
*/ |
| 17 |
|
| 18 |
/** Keyword lists per language. */ |
| 19 |
function highlight_keywords(string $lang): array |
| 20 |
{ |
| 21 |
switch ($lang) { |
| 22 |
case 'php': |
| 23 |
return ['abstract','and','array','as','break','callable','case','catch', |
| 24 |
'class','clone','const','continue','declare','default','do','echo','else', |
| 25 |
'elseif','empty','enddeclare','endfor','endforeach','endif','endswitch', |
| 26 |
'endwhile','enum','extends','final','finally','fn','for','foreach','function', |
| 27 |
'global','goto','if','implements','include','include_once','instanceof', |
| 28 |
'insteadof','interface','isset','list','match','namespace','new','or','print', |
| 29 |
'private','protected','public','readonly','require','require_once','return', |
| 30 |
'static','switch','throw','trait','try','unset','use','var','while','xor', |
| 31 |
'yield','true','false','null']; |
| 32 |
case 'javascript': |
| 33 |
return ['await','async','break','case','catch','class','const','continue', |
| 34 |
'debugger','default','delete','do','else','export','extends','finally','for', |
| 35 |
'function','if','import','in','instanceof','let','new','of','return','super', |
| 36 |
'switch','this','throw','try','typeof','var','void','while','with','yield', |
| 37 |
'true','false','null','undefined']; |
| 38 |
case 'python': |
| 39 |
return ['and','as','assert','async','await','break','class','continue','def', |
| 40 |
'del','elif','else','except','finally','for','from','global','if','import', |
| 41 |
'in','is','lambda','nonlocal','not','or','pass','raise','return','try','while', |
| 42 |
'with','yield','True','False','None','self']; |
| 43 |
case 'java': |
| 44 |
return ['abstract','assert','boolean','break','byte','case','catch','char', |
| 45 |
'class','const','continue','default','do','double','else','enum','extends', |
| 46 |
'final','finally','float','for','goto','if','implements','import','instanceof', |
| 47 |
'int','interface','long','native','new','package','private','protected', |
| 48 |
'public','return','short','static','strictfp','super','switch','synchronized', |
| 49 |
'this','throw','throws','transient','try','void','volatile','while','var', |
| 50 |
'true','false','null']; |
| 51 |
case 'c': |
| 52 |
return ['auto','break','case','char','const','continue','default','do','double', |
| 53 |
'else','enum','extern','float','for','goto','if','inline','int','long', |
| 54 |
'register','restrict','return','short','signed','sizeof','static','struct', |
| 55 |
'switch','typedef','union','unsigned','void','volatile','while','bool','true', |
| 56 |
'false','NULL']; |
| 57 |
case 'cpp': |
| 58 |
return ['alignas','auto','bool','break','case','catch','char','class','const', |
| 59 |
'constexpr','continue','default','delete','do','double','else','enum','explicit', |
| 60 |
'extern','false','float','for','friend','goto','if','inline','int','long', |
| 61 |
'namespace','new','nullptr','operator','override','private','protected','public', |
| 62 |
'return','short','signed','sizeof','static','struct','switch','template','this', |
| 63 |
'throw','true','try','typedef','typename','union','unsigned','using','virtual', |
| 64 |
'void','volatile','while']; |
| 65 |
case 'go': |
| 66 |
return ['break','case','chan','const','continue','default','defer','else', |
| 67 |
'fallthrough','for','func','go','goto','if','import','interface','map','package', |
| 68 |
'range','return','select','struct','switch','type','var','true','false','nil', |
| 69 |
'iota','string','int','int64','float64','bool','byte','rune','error']; |
| 70 |
case 'ruby': |
| 71 |
return ['def','end','if','elsif','else','unless','while','until','for','in','do', |
| 72 |
'begin','rescue','ensure','raise','return','yield','class','module','self','nil', |
| 73 |
'true','false','and','or','not','then','case','when','break','next','redo', |
| 74 |
'retry','super','require','require_relative','attr_accessor','attr_reader', |
| 75 |
'attr_writer','puts','new']; |
| 76 |
case 'sql': |
| 77 |
return ['select','from','where','insert','into','values','update','set','delete', |
| 78 |
'create','alter','drop','table','database','view','index','join','left','right', |
| 79 |
'inner','outer','full','cross','on','as','and','or','not','null','is','in', |
| 80 |
'between','like','order','by','group','having','limit','offset','distinct', |
| 81 |
'union','all','asc','desc','primary','key','foreign','references','unique', |
| 82 |
'default','constraint','auto_increment','int','integer','varchar','text','char', |
| 83 |
'datetime','timestamp','date','boolean','count','sum','avg','min','max','case', |
| 84 |
'when','then','else','end','if','exists','begin','commit','rollback']; |
| 85 |
case 'bash': |
| 86 |
return ['if','then','else','elif','fi','for','while','until','do','done','case', |
| 87 |
'esac','function','in','select','return','break','continue','local','export', |
| 88 |
'readonly','declare','unset','echo','printf','cd','exit','test','true','false', |
| 89 |
'source','alias']; |
| 90 |
default: |
| 91 |
return []; |
| 92 |
} |
| 93 |
} |
| 94 |
|
| 95 |
/** Comment pattern for a keyword-based language. */ |
| 96 |
function highlight_comment(string $lang): string |
| 97 |
{ |
| 98 |
switch ($lang) { |
| 99 |
case 'python': |
| 100 |
case 'ruby': |
| 101 |
case 'bash': |
| 102 |
return '#[^\n]*'; |
| 103 |
case 'sql': |
| 104 |
return '--[^\n]*|\/\*[\s\S]*?\*\/'; |
| 105 |
default: // php, js, java, c, cpp, go |
| 106 |
return '\/\/[^\n]*|\/\*[\s\S]*?\*\/'; |
| 107 |
} |
| 108 |
} |
| 109 |
|
| 110 |
/** Build the master regex for a language, or null for the plain fallback. */ |
| 111 |
function highlight_regex(string $lang): ?string |
| 112 |
{ |
| 113 |
// --- Structural formats with tailored token sets. --- |
| 114 |
if ($lang === 'css') { |
| 115 |
return '/(?<com>\/\*[\s\S]*?\*\/)' |
| 116 |
. '|(?<str>"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')' |
| 117 |
. '|(?<kw>@[\w-]+|!important)' |
| 118 |
. '|(?<fn>[\w-]+(?=\s*\())' |
| 119 |
. '|(?<attr>[A-Za-z-]+(?=\s*:))' |
| 120 |
. '|(?<num>#[0-9A-Fa-f]{3,8}\b|\b\d+(?:\.\d+)?(?:px|em|rem|ex|ch|vw|vh|vmin|vmax|%|pt|cm|mm|in|deg|s|ms|fr)?\b)' |
| 121 |
. '|(?<sel>[.#][A-Za-z_][\w-]*|::?[A-Za-z-]+)/'; |
| 122 |
} |
| 123 |
if ($lang === 'html') { |
| 124 |
return '/(?<com><!--[\s\S]*?-->)' |
| 125 |
. '|(?<str>"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')' |
| 126 |
. '|(?<tag><\/?[A-Za-z][\w:-]*|\/?>)' |
| 127 |
. '|(?<attr>\s[A-Za-z_:][\w:.-]*(?=\s*=))/'; |
| 128 |
} |
| 129 |
if ($lang === 'json') { |
| 130 |
return '/(?<attr>"(?:\\\\.|[^"\\\\])*"(?=\s*:))' |
| 131 |
. '|(?<str>"(?:\\\\.|[^"\\\\])*")' |
| 132 |
. '|(?<kw>\b(?:true|false|null)\b)' |
| 133 |
. '|(?<num>-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)/'; |
| 134 |
} |
| 135 |
if ($lang === 'markdown') { |
| 136 |
return '/(?<tag>^\s{0,3}#{1,6}\s.*$|^\s*[-*+]\s|^\s*>\s)' |
| 137 |
. '|(?<str>`[^`]+`)' |
| 138 |
. '|(?<kw>\*\*[^*]+\*\*|__[^_]+__)' |
| 139 |
. '|(?<fn>\[[^\]]+\]\([^)]+\))/m'; |
| 140 |
} |
| 141 |
|
| 142 |
// --- Keyword-based languages share one builder. --- |
| 143 |
$keywords = highlight_keywords($lang); |
| 144 |
if (!$keywords) { |
| 145 |
return null; |
| 146 |
} |
| 147 |
|
| 148 |
$comment = highlight_comment($lang); |
| 149 |
|
| 150 |
// String styles (single, double; backticks for js/bash). |
| 151 |
$string = '"(?:\\\\.|[^"\\\\])*"' . '|' . "'(?:\\\\.|[^'\\\\])*'"; |
| 152 |
if ($lang === 'javascript' || $lang === 'bash') { |
| 153 |
$string .= '|`(?:\\\\.|[^`\\\\])*`'; |
| 154 |
} |
| 155 |
|
| 156 |
$number = '\b\d+(?:\.\d+)?\b'; |
| 157 |
if ($lang === 'php' || $lang === 'bash') { |
| 158 |
$variable = '\$\{[^}]+\}|\$[A-Za-z_]\w*'; |
| 159 |
} else { |
| 160 |
$variable = '(?!)'; // never matches |
| 161 |
} |
| 162 |
$kw = '\b(?:' . implode('|', array_map('preg_quote', $keywords)) . ')\b'; |
| 163 |
$func = '[A-Za-z_]\w*(?=\s*\()'; |
| 164 |
|
| 165 |
$flags = ($lang === 'sql') ? 'i' : ''; |
| 166 |
|
| 167 |
return '/(?<com>' . $comment . ')' |
| 168 |
. '|(?<str>' . $string . ')' |
| 169 |
. '|(?<num>' . $number . ')' |
| 170 |
. '|(?<var>' . $variable . ')' |
| 171 |
. '|(?<kw>' . $kw . ')' |
| 172 |
. '|(?<fn>' . $func . ')/' . $flags; |
| 173 |
} |
| 174 |
|
| 175 |
/** |
| 176 |
* Return HTML for the given source code with token <span>s. The result is safe |
| 177 |
* to embed directly (all literal text is HTML-escaped). |
| 178 |
*/ |
| 179 |
function highlight_code(string $code, string $lang): string |
| 180 |
{ |
| 181 |
$lang = strtolower($lang); |
| 182 |
$regex = highlight_regex($lang); |
| 183 |
|
| 184 |
// Unknown language: escape and return as-is. |
| 185 |
if ($regex === null) { |
| 186 |
return htmlspecialchars($code, ENT_QUOTES, 'UTF-8'); |
| 187 |
} |
| 188 |
|
| 189 |
// Every group name that may appear in any language's regex. |
| 190 |
$groups = ['com', 'str', 'num', 'var', 'kw', 'fn', 'tag', 'attr', 'sel']; |
| 191 |
$out = ''; |
| 192 |
$offset = 0; |
| 193 |
|
| 194 |
if (preg_match_all($regex, $code, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { |
| 195 |
foreach ($matches as $m) { |
| 196 |
$full = $m[0][0]; |
| 197 |
$pos = $m[0][1]; |
| 198 |
|
| 199 |
if ($pos > $offset) { |
| 200 |
$out .= htmlspecialchars(substr($code, $offset, $pos - $offset), ENT_QUOTES, 'UTF-8'); |
| 201 |
} |
| 202 |
|
| 203 |
$class = null; |
| 204 |
foreach ($groups as $g) { |
| 205 |
if (isset($m[$g]) && $m[$g][1] !== -1 && $m[$g][0] !== '') { |
| 206 |
$class = $g; |
| 207 |
break; |
| 208 |
} |
| 209 |
} |
| 210 |
|
| 211 |
// Preserve any leading whitespace captured by a token (e.g. HTML |
| 212 |
// attributes, markdown list markers) outside the coloured span. |
| 213 |
$lead = ''; |
| 214 |
if ($class !== null && ($class === 'attr' || $class === 'tag') && preg_match('/^\s+/', $full, $ws)) { |
| 215 |
$lead = $ws[0]; |
| 216 |
$full = substr($full, strlen($lead)); |
| 217 |
} |
| 218 |
|
| 219 |
$esc = htmlspecialchars($full, ENT_QUOTES, 'UTF-8'); |
| 220 |
$out .= $lead . ($class ? '<span class="tok-' . $class . '">' . $esc . '</span>' : $esc); |
| 221 |
|
| 222 |
$offset = $pos + strlen($m[0][0]); |
| 223 |
} |
| 224 |
} |
| 225 |
|
| 226 |
if ($offset < strlen($code)) { |
| 227 |
$out .= htmlspecialchars(substr($code, $offset), ENT_QUOTES, 'UTF-8'); |
| 228 |
} |
| 229 |
|
| 230 |
return $out; |
| 231 |
} |
| 232 |
|