428 lines
12 KiB
PHP
428 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\SyntaxHighlighter\Formatters;
|
|
|
|
use App\Framework\Tokenizer\ValueObjects\Token;
|
|
use App\Framework\Tokenizer\ValueObjects\TokenCollection;
|
|
use App\Framework\Tokenizer\ValueObjects\TokenType;
|
|
|
|
/**
|
|
* HTML formatter for the modern tokenizer
|
|
*/
|
|
final class HtmlFormatter implements FormatterInterface
|
|
{
|
|
private static bool $cssOutput = false;
|
|
|
|
private array $customTheme = [];
|
|
|
|
/**
|
|
* Format tokens as HTML
|
|
*/
|
|
public function format(TokenCollection|array $tokens, array $options = []): string
|
|
{
|
|
// Handle both old array format and new TokenCollection
|
|
if (is_array($tokens)) {
|
|
// Legacy support for old Token format
|
|
return $this->formatLegacy($tokens, $options);
|
|
}
|
|
|
|
$includeCss = $options['includeCss'] ?? false;
|
|
$lineNumbers = $options['lineNumbers'] ?? true;
|
|
$lineOffset = $options['lineOffset'] ?? 0;
|
|
$highlightLines = $options['highlightLines'] ?? [];
|
|
$wrapInPre = $options['wrapInPre'] ?? true;
|
|
|
|
$html = '';
|
|
|
|
// Include CSS if requested and not already output
|
|
if ($includeCss && ! self::$cssOutput) {
|
|
$html .= $this->getCss($options);
|
|
self::$cssOutput = true;
|
|
}
|
|
|
|
// Start wrapper
|
|
if ($wrapInPre) {
|
|
$html .= '<pre class="syntax-highlight"><code>';
|
|
}
|
|
|
|
// Group tokens by line for line number display
|
|
$tokensByLine = $tokens->groupByLine();
|
|
|
|
// Fill in missing lines (empty lines between code)
|
|
if (!empty($tokensByLine)) {
|
|
$minLine = min(array_keys($tokensByLine));
|
|
$maxLine = max(array_keys($tokensByLine));
|
|
|
|
for ($line = $minLine; $line <= $maxLine; $line++) {
|
|
if (!isset($tokensByLine[$line])) {
|
|
$tokensByLine[$line] = []; // Empty line
|
|
}
|
|
}
|
|
ksort($tokensByLine); // Sort by line number
|
|
}
|
|
|
|
foreach ($tokensByLine as $lineNum => $lineTokens) {
|
|
$displayLineNum = $lineNum + $lineOffset;
|
|
$isHighlighted = in_array($displayLineNum, $highlightLines, true);
|
|
|
|
// Add line wrapper
|
|
$lineClass = $isHighlighted ? 'line highlighted' : 'line';
|
|
$html .= sprintf('<div class="%s">', $lineClass);
|
|
|
|
// Add line number
|
|
if ($lineNumbers) {
|
|
$html .= sprintf('<span class="line-number">%d</span>', $displayLineNum);
|
|
}
|
|
|
|
// Add tokens
|
|
$html .= '<span class="code">';
|
|
foreach ($lineTokens as $token) {
|
|
$html .= $this->formatSingle($token, $options);
|
|
}
|
|
$html .= '</span></div>';
|
|
}
|
|
|
|
// End wrapper
|
|
if ($wrapInPre) {
|
|
$html .= '</code></pre>';
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* Format a single token
|
|
*/
|
|
public function formatSingle(Token $token, array $options = []): string
|
|
{
|
|
// Handle whitespace tokens
|
|
if ($token->type === TokenType::WHITESPACE) {
|
|
// Remove newlines from whitespace - we handle line breaks via <div class="line">
|
|
$whitespace = str_replace(["\r\n", "\n", "\r"], '', $token->value);
|
|
|
|
// If empty after removing newlines, skip it
|
|
if ($whitespace === '') {
|
|
return '';
|
|
}
|
|
|
|
// Wrap in span to ensure white-space: pre CSS is applied
|
|
// Without the span, HTML collapses multiple spaces to one
|
|
$escapedValue = htmlspecialchars($whitespace, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
return sprintf('<span class="token-whitespace">%s</span>', $escapedValue);
|
|
}
|
|
|
|
$cssClass = $token->type->getCssClass();
|
|
$escapedValue = htmlspecialchars($token->value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
return sprintf('<span class="%s">%s</span>', $cssClass, $escapedValue);
|
|
}
|
|
|
|
/**
|
|
* Get CSS for syntax highlighting
|
|
*/
|
|
public function getCss(array $options = []): string
|
|
{
|
|
// Use custom theme if set via setTheme()
|
|
if (! empty($this->customTheme)) {
|
|
return $this->generateCustomThemeCss($this->customTheme);
|
|
}
|
|
|
|
$theme = $options['theme'] ?? 'default';
|
|
|
|
return match($theme) {
|
|
'dark' => $this->getDarkThemeCss(),
|
|
'light' => $this->getLightThemeCss(),
|
|
default => $this->getDefaultThemeCss()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get formatter name
|
|
*/
|
|
public function getName(): string
|
|
{
|
|
return 'html';
|
|
}
|
|
|
|
/**
|
|
* Check if formatter supports option
|
|
*/
|
|
public function supportsOption(string $option): bool
|
|
{
|
|
return in_array($option, [
|
|
'includeCss',
|
|
'lineNumbers',
|
|
'lineOffset',
|
|
'highlightLines',
|
|
'wrapInPre',
|
|
'showWhitespace',
|
|
'theme',
|
|
], true);
|
|
}
|
|
|
|
/**
|
|
* Reset CSS output flag
|
|
*/
|
|
public static function resetCssOutput(): void
|
|
{
|
|
self::$cssOutput = false;
|
|
}
|
|
|
|
/**
|
|
* Set custom theme colors (backward compatibility)
|
|
*/
|
|
public function setTheme(array $theme): self
|
|
{
|
|
$this->customTheme = $theme;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Legacy format method for old Token format
|
|
*/
|
|
private function formatLegacy(array $tokens, array $options = []): string
|
|
{
|
|
$html = '';
|
|
foreach ($tokens as $token) {
|
|
if (is_object($token) && property_exists($token, 'type') && property_exists($token, 'value')) {
|
|
// Map old TokenType to CSS class
|
|
$cssClass = 'token-' . str_replace('_', '-', strtolower($token->type->name));
|
|
$escapedValue = htmlspecialchars($token->value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$html .= sprintf('<span class="%s">%s</span>', $cssClass, $escapedValue);
|
|
}
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* Get default theme CSS
|
|
*/
|
|
private function getDefaultThemeCss(): string
|
|
{
|
|
return <<<'CSS'
|
|
<style>
|
|
.syntax-highlight {
|
|
background: #2d2d2d;
|
|
color: #f8f8f2;
|
|
padding: 1em;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.syntax-highlight .line {
|
|
display: flex;
|
|
min-height: 1.5em;
|
|
}
|
|
|
|
.syntax-highlight .line.highlighted {
|
|
background: rgba(255, 255, 100, 0.1);
|
|
}
|
|
|
|
.syntax-highlight .line-number {
|
|
color: #666;
|
|
padding-right: 1em;
|
|
user-select: none;
|
|
min-width: 3em;
|
|
text-align: right;
|
|
}
|
|
|
|
.syntax-highlight .code {
|
|
flex: 1;
|
|
white-space: pre;
|
|
}
|
|
|
|
/* Whitespace tokens must preserve spaces */
|
|
.token-whitespace {
|
|
white-space: pre;
|
|
}
|
|
|
|
/* Token colors */
|
|
.token-keyword { color: #ff79c6; font-weight: bold; }
|
|
.token-class-name { color: #8be9fd; }
|
|
.token-interface-name { color: #8be9fd; }
|
|
.token-trait-name { color: #8be9fd; }
|
|
.token-enum-name { color: #8be9fd; }
|
|
.token-namespace-name { color: #50fa7b; }
|
|
.token-function-name { color: #f1fa8c; }
|
|
.token-method-name { color: #f1fa8c; }
|
|
.token-property-name { color: #bd93f9; }
|
|
.token-constant-name { color: #ff5555; }
|
|
.token-variable { color: #f8f8f2; }
|
|
.token-parameter { color: #ffb86c; }
|
|
.token-string-literal { color: #50fa7b; }
|
|
.token-number-literal { color: #bd93f9; }
|
|
.token-boolean-literal { color: #bd93f9; }
|
|
.token-null-literal { color: #bd93f9; }
|
|
.token-comment { color: #6272a4; font-style: italic; }
|
|
.token-doc-comment { color: #6272a4; font-style: italic; }
|
|
.token-doc-tag { color: #ff79c6; font-style: italic; }
|
|
.token-doc-type { color: #8be9fd; font-style: italic; }
|
|
.token-doc-variable { color: #ffb86c; font-style: italic; }
|
|
.token-operator { color: #ff79c6; }
|
|
.token-punctuation { color: #f8f8f2; }
|
|
.token-attribute { color: #ff79c6; }
|
|
.token-attribute-name { color: #f1fa8c; }
|
|
.token-type-hint { color: #8be9fd; }
|
|
.token-php-tag { color: #ff79c6; }
|
|
.token-error { color: #ff5555; background: rgba(255, 85, 85, 0.1); }
|
|
</style>
|
|
CSS;
|
|
}
|
|
|
|
/**
|
|
* Get dark theme CSS
|
|
*/
|
|
private function getDarkThemeCss(): string
|
|
{
|
|
return $this->getDefaultThemeCss(); // Dark is default
|
|
}
|
|
|
|
/**
|
|
* Get light theme CSS
|
|
*/
|
|
private function getLightThemeCss(): string
|
|
{
|
|
return <<<'CSS'
|
|
<style>
|
|
.syntax-highlight {
|
|
background: #fafafa;
|
|
color: #383a42;
|
|
padding: 1em;
|
|
border: 1px solid #e1e4e8;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
font-family: 'Fira Code', 'Consolas', monospace;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.syntax-highlight .line {
|
|
display: flex;
|
|
min-height: 1.5em;
|
|
}
|
|
|
|
.syntax-highlight .line.highlighted {
|
|
background: rgba(255, 251, 0, 0.2);
|
|
}
|
|
|
|
.syntax-highlight .line-number {
|
|
color: #969896;
|
|
padding-right: 1em;
|
|
user-select: none;
|
|
min-width: 3em;
|
|
text-align: right;
|
|
}
|
|
|
|
.syntax-highlight .code {
|
|
flex: 1;
|
|
white-space: pre;
|
|
}
|
|
|
|
/* Whitespace tokens must preserve spaces */
|
|
.token-whitespace {
|
|
white-space: pre;
|
|
}
|
|
|
|
/* Light theme token colors */
|
|
.token-keyword { color: #a626a4; font-weight: bold; }
|
|
.token-class-name { color: #c18401; }
|
|
.token-function-name { color: #4078f2; }
|
|
.token-method-name { color: #4078f2; }
|
|
.token-property-name { color: #986801; }
|
|
.token-constant-name { color: #986801; }
|
|
.token-variable { color: #383a42; }
|
|
.token-parameter { color: #e45649; }
|
|
.token-string-literal { color: #50a14f; }
|
|
.token-number-literal { color: #986801; }
|
|
.token-comment { color: #a0a1a7; font-style: italic; }
|
|
.token-operator { color: #a626a4; }
|
|
.token-attribute { color: #a626a4; }
|
|
.token-type-hint { color: #c18401; }
|
|
.token-error { color: #e45649; background: rgba(228, 86, 73, 0.1); }
|
|
</style>
|
|
CSS;
|
|
}
|
|
|
|
/**
|
|
* Generate custom theme CSS from theme array
|
|
*/
|
|
private function generateCustomThemeCss(array $theme): string
|
|
{
|
|
$background = $theme['background'] ?? '#2b2b2b';
|
|
$color = $theme['identifier'] ?? '#a9b7c6';
|
|
$border = $theme['border'] ?? '#555555';
|
|
$lineNumber = $theme['line-number'] ?? '#606366';
|
|
|
|
// Build CSS with custom colors
|
|
$css = "<style>\n";
|
|
$css .= ".syntax-highlight {\n";
|
|
$css .= " background: {$background};\n";
|
|
$css .= " color: {$color};\n";
|
|
$css .= " padding: 1em;\n";
|
|
$css .= " border: 1px solid {$border};\n";
|
|
$css .= " border-radius: 4px;\n";
|
|
$css .= " overflow-x: auto;\n";
|
|
$css .= " font-family: 'Fira Code', 'Consolas', monospace;\n";
|
|
$css .= " font-size: 14px;\n";
|
|
$css .= " line-height: 1.5;\n";
|
|
$css .= "}\n\n";
|
|
|
|
$css .= ".syntax-highlight .line { display: flex; min-height: 1.5em; }\n";
|
|
$css .= ".syntax-highlight .line.highlighted { background: rgba(255, 255, 100, 0.1); }\n";
|
|
$css .= ".syntax-highlight .line-number {\n";
|
|
$css .= " color: {$lineNumber};\n";
|
|
$css .= " padding-right: 1em;\n";
|
|
$css .= " user-select: none;\n";
|
|
$css .= " min-width: 3em;\n";
|
|
$css .= " text-align: right;\n";
|
|
$css .= "}\n";
|
|
$css .= ".syntax-highlight .code { flex: 1; white-space: pre; }\n";
|
|
$css .= ".token-whitespace { white-space: pre; }\n\n";
|
|
|
|
// Map theme keys to CSS token classes
|
|
$tokenMapping = [
|
|
'keyword' => 'token-keyword',
|
|
'variable' => 'token-variable',
|
|
'string' => 'token-string-literal',
|
|
'number' => 'token-number-literal',
|
|
'comment' => 'token-comment',
|
|
'doc-comment' => 'token-doc-comment',
|
|
'php-tag' => 'token-php-tag',
|
|
'operator' => 'token-operator',
|
|
'constant' => 'token-constant-name',
|
|
'method-call' => 'token-method-name',
|
|
'property' => 'token-property-name',
|
|
'class-name' => 'token-class-name',
|
|
];
|
|
|
|
// Generate token-specific styles
|
|
foreach ($tokenMapping as $themeKey => $cssClass) {
|
|
if (isset($theme[$themeKey])) {
|
|
$css .= ".{$cssClass} { color: {$theme[$themeKey]}; }\n";
|
|
}
|
|
}
|
|
|
|
// Special cases with additional styling
|
|
if (isset($theme['keyword'])) {
|
|
$css .= ".token-keyword { color: {$theme['keyword']}; font-weight: bold; }\n";
|
|
}
|
|
if (isset($theme['comment'])) {
|
|
$css .= ".token-comment { color: {$theme['comment']}; font-style: italic; }\n";
|
|
}
|
|
if (isset($theme['doc-comment'])) {
|
|
$css .= ".token-doc-comment { color: {$theme['doc-comment']}; font-style: italic; }\n";
|
|
}
|
|
|
|
$css .= "</style>";
|
|
|
|
return $css;
|
|
}
|
|
}
|