`. Good enough for documentation snippets; * output is always escaped, and tokens never span lines (so the code can be * split into numbered lines afterwards). */ class Highlighter { /** * Languages offered in the editor: id => label. */ const LANGUAGES = array( 'bash' => 'Bash / Shell', 'css' => 'CSS', 'diff' => 'Diff', 'go' => 'Go', 'html' => 'HTML / XML', 'java' => 'Java', 'javascript' => 'JavaScript', 'json' => 'JSON', 'php' => 'PHP', 'python' => 'Python', 'ruby' => 'Ruby', 'sql' => 'SQL', 'typescript' => 'TypeScript', 'yaml' => 'YAML', 'mermaid' => 'Mermaid diagram', 'text' => 'Plain text', ); /** * Keywords per language. */ const KEYWORDS = array( 'bash' => 'if then else elif fi for in do done while until case esac function return local export echo exit sudo cd source set unset', 'css' => 'important media import supports keyframes from to root', 'go' => 'break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false', 'java' => 'abstract boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long new package private protected public return short static super switch this throw throws try void while null true false var record', 'javascript' => 'async await break case catch class const continue default delete do else export extends finally for from function if import in instanceof let new of return static super switch this throw try typeof var void while yield null undefined true false', 'json' => 'true false null', 'php' => 'abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum extends final finally fn for foreach function global if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public readonly require require_once return static switch throw trait try unset use var while yield null true false self parent', 'python' => 'and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield None True False self', 'ruby' => 'alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield require attr_accessor', 'sql' => 'select from where and or not insert into values update set delete create table alter drop index join left right inner outer on group by order having limit offset as distinct null is in like between union all primary key foreign references default', 'typescript' => 'async await break case catch class const continue default delete do else enum export extends finally for from function if implements import in instanceof interface let new of private protected public readonly return static super switch this throw try type typeof var void while yield null undefined true false string number boolean any unknown never as', 'yaml' => 'true false null yes no on off', ); /** * Language id from a name or alias ("js" → javascript). * * @param string $lang Language. * @return string */ public static function language( $lang ) { $lang = strtolower( trim( (string) $lang ) ); $aliases = array( 'js' => 'javascript', 'jsx' => 'javascript', 'ts' => 'typescript', 'tsx' => 'typescript', 'sh' => 'bash', 'shell' => 'bash', 'zsh' => 'bash', 'xml' => 'html', 'py' => 'python', 'rb' => 'ruby', 'yml' => 'yaml', ); $lang = $aliases[ $lang ] ?? $lang; return isset( self::LANGUAGES[ $lang ] ) ? $lang : 'text'; } /** * Token rules for a language: [class, pattern] in priority order. * * @param string $lang Language id. * @return array[] */ private static function rules( $lang ) { $dq = '"(?:\\\\.|[^"\\\\\n])*"'; $sq = "'(?:\\\\.|[^'\\\\\\n])*'"; $c_block = '/\*[\s\S]*?\*/'; $c_line = '//[^\n]*'; $hash = '#[^\n]*'; $number = '\b\d+(?:\.\d+)?\b'; $function = '\b[A-Za-z_][\w]*(?=\s*\()'; switch ( $lang ) { case 'html': return array( array( 'comment', '' ), array( 'tag', '' ), array( 'attr', '(?<=\s)[A-Za-z_:][\w:.-]*(?==)' ), array( 'string', $dq . '|' . $sq ), ); case 'css': return array( array( 'comment', $c_block ), array( 'string', $dq . '|' . $sq ), array( 'keyword', '@[\w-]+|!important' ), array( 'property', '[\w-]+(?=\s*:[^:{]*[;}])' ), array( 'number', '#[0-9a-fA-F]{3,8}\b|\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg|fr)?\b' ), ); case 'json': return array( array( 'property', $dq . '(?=\s*:)' ), array( 'string', $dq ), array( 'number', '-?' . $number ), array( 'keyword', '\b(?:true|false|null)\b' ), ); case 'yaml': return array( array( 'comment', $hash ), array( 'property', '^[ \t-]*[\w.-]+(?=\s*:)' ), array( 'string', $dq . '|' . $sq ), array( 'number', $number ), array( 'keyword', self::keyword_pattern( 'yaml' ) ), ); case 'sql': return array( array( 'comment', '--[^\n]*|' . $c_block ), array( 'string', $sq . '|' . $dq ), array( 'number', $number ), array( 'keyword', '(?i)' . self::keyword_pattern( 'sql' ) . '(?-i)' ), array( 'function', $function ), ); case 'bash': return array( array( 'comment', '(?|' . self::keyword_pattern( 'php' ) ), array( 'number', $number ), array( 'function', $function ), ); case 'go': case 'java': case 'javascript': case 'typescript': return array( array( 'comment', $c_block . '|' . $c_line ), array( 'string', $dq . '|' . $sq . '|`(?:\\\\.|[^`\\\\])*`' ), array( 'keyword', self::keyword_pattern( $lang ) ), array( 'number', $number ), array( 'function', $function ), ); } return array(); } /** * Word-boundary alternation of a language's keywords. * * @param string $lang Language. * @return string */ private static function keyword_pattern( $lang ) { return '\b(?:' . implode( '|', array_map( 'preg_quote', explode( ' ', self::KEYWORDS[ $lang ] ) ) ) . ')\b'; } /** * Highlighted, escaped HTML of a code snippet. * * @param string $code Code (plain text). * @param string $lang Language. * @return string */ public static function highlight( $code, $lang ) { $rules = self::rules( self::language( $lang ) ); if ( ! $rules ) { return esc_html( $code ); } $names = array(); $pattern = array(); foreach ( $rules as $i => $rule ) { $names[ 'g' . $i ] = $rule[0]; $pattern[] = '(?P' . $rule[1] . ')'; } $regex = '~' . implode( '|', $pattern ) . '~m'; if ( ! preg_match_all( $regex, $code, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return esc_html( $code ); } $out = ''; $pos = 0; foreach ( $matches as $match ) { $offset = $match[0][1]; $text = $match[0][0]; if ( '' === $text || $offset < $pos ) { continue; } $class = 'plain'; foreach ( $names as $group => $name ) { if ( isset( $match[ $group ] ) && -1 !== $match[ $group ][1] && '' !== $match[ $group ][0] ) { $class = $name; break; } } $out .= esc_html( substr( $code, $pos, $offset - $pos ) ); // One span per line so the result can be split into lines. $out .= implode( "\n", array_map( static fn( $part ) => '' === $part ? '' : '' . esc_html( $part ) . '', explode( "\n", $text ) ) ); $pos = $offset + strlen( $text ); } return $out . esc_html( substr( $code, $pos ) ); } /** * Line numbers from a spec like "2,5-7". * * @param string $spec Spec. * @return int[] */ public static function lines( $spec ) { $lines = array(); foreach ( explode( ',', preg_replace( '/[^\d,\-]/', '', (string) $spec ) ) as $part ) { if ( preg_match( '/^(\d+)-(\d+)$/', $part, $m ) ) { $lines = array_merge( $lines, range( (int) $m[1], min( (int) $m[2], (int) $m[1] + 500 ) ) ); } elseif ( ctype_digit( $part ) ) { $lines[] = (int) $part; } } return array_values( array_unique( $lines ) ); } }