diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f55c69..331924b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,4 +28,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.1.0] - 2023-06-13 - Set PHP minimum version in composer - Throws UnknownThemeException -- Added tests \ No newline at end of file +- Added tests + +## [3.0.0] - 2025-11-21 + +### Added +- Support for `
` pattern (in addition to `
`) for better Markdown compatibility
+- Support for `class="language-*"` attribute on `` tag for language detection
+- Fluent interface for `showLineNumbers()` and `showActionPanel()` methods
+- `HighlighterFactory` class for creating highlighter instances based on language
+- `CodeBlockWrapper` class for separating presentation logic from highlighting
+- `LanguageNormalizer` class for normalizing language identifiers with aliases support
+- Custom exceptions: `InvalidLanguageException`, `InvalidThemeException`, `ThemeNotSetException`
+- PHP as default language for code blocks without specified language
+- Comprehensive test suite
+
+### Changed
+- **BREAKING**: Removed Singleton pattern from all highlighter classes (`HighlighterPHP`, `HighlighterXML`, `HighlighterBash`)
+- **BREAKING**: Made `HighlighterBase` an abstract class (cannot be instantiated directly)
+- **BREAKING**: Added `ext-dom` PHP extension requirement (previously optional with regex fallback)
+- Improved HTML attribute parsing using `DOMDocument` with regex fallback
+- Enhanced syntax highlighting logic in `HighlighterBase`
+- Improved XML/HTML highlighting: fixed line-by-line processing, proper attribute highlighting
+- Updated PHPDoc comments throughout the codebase
+
+### Fixed
+- Fixed issue with extra empty lines at the beginning and end of code blocks
+- Fixed line numbering
diff --git a/README.md b/README.md
index 4c6ce61..beb2372 100644
--- a/README.md
+++ b/README.md
@@ -3,11 +3,13 @@
 PHPHighlight is a PHP syntax highlighting library that can be easily customized and extended.
 
 ## How it works
-The library parses the text, finds the \
 tag, reads the attributes (data-lang, data-file, data-theme) and highlights the syntax of the code block.
+The library parses the text, finds the `
` and `
` tags, reads the attributes (data-lang, data-file, data-theme) and highlights the syntax of the code block.
+
+**Recommended:** Use `
` pattern for better semantics and compatibility with Markdown output.
 
 Supports style customization. Here are examples of styling:
 
-styling example
+styling example
 
 ## Requirements
 PHP 8.1+
@@ -19,7 +21,7 @@ $ composer require demyanovs/php-highlight
 ```
 
 ## Usage
-See full example here [index.php](../master/examples/index.php)
+See full example in [examples/index.php](examples/index.php)
 ```php
 
+

 <?php
 abstract class AbstractClass
 {
@@ -62,13 +64,12 @@ class ConcreteClass extends AbstractClass
 $class = new ConcreteClass;
 echo $class->prefixName("Pacman"), "\n";
 echo $class->prefixName("Pacwoman"), "\n";
-
+
'; -$highlighter = new Highlighter($text, ObsidianTheme::TITLE); -// Configuration -$highlighter->showLineNumbers(true); -$highlighter->showActionPanel(true); +$highlighter = (new Highlighter($text, ObsidianTheme::TITLE)) + ->showLineNumbers(true) + ->showActionPanel(true); echo $highlighter->parse(); ``` @@ -78,19 +79,53 @@ $highlighter->showLineNumbers(true); $highlighter->showActionPanel(true); ``` -You can set following attributes in \
 tag
-\
-* lang - a language of the text. This affects how the parser will highlight the syntax.
-* file - show file name in action panel.
-* theme - allows to overwrite the global theme.
+You can set following attributes in `
` or `` tags:
+```html
+

+// or
+

+```
+
+* `data-lang` or `class="language-*"` - a language of the text. This affects how the parser will highlight the syntax.
+* `data-file` - show file name in action panel.
+* `data-theme` - allows to overwrite the global theme.
+
+**Note:** `class="language-*"` on `` tag is automatically recognized (common in Markdown output).
 
 ### How to create a custom theme
-To create a custom theme you need to create an instance of Demyanovs\PHPHighlight\Themes\Theme class
+To create a custom theme you need to create an instance of `Demyanovs\PHPHighlight\Themes\Theme` class
 and pass it to Highlighter as a third argument:
 ```php
-$defaultColorSchemaDto = new DefaultColorSchemaDto(...);
-$PHPColorSchemaDto = new PHPColorSchemaDto(...);
-$XMLColorSchemaDto = new XMLColorSchemaDto(...);
+use Demyanovs\PHPHighlight\Highlighter;
+use Demyanovs\PHPHighlight\Themes\Theme;
+use Demyanovs\PHPHighlight\Themes\Dto\DefaultColorSchemaDto;
+use Demyanovs\PHPHighlight\Themes\Dto\PHPColorSchemaDto;
+use Demyanovs\PHPHighlight\Themes\Dto\XMLColorSchemaDto;
+
+$defaultColorSchemaDto = new DefaultColorSchemaDto(
+    '#000000', // background
+    '#ffffff', // default text
+    '#888888', // comment
+    '#ff0000', // keyword
+    '#00ff00', // string
+    '#0000ff', // number
+    '#ffff00'  // variable
+);
+
+$PHPColorSchemaDto = new PHPColorSchemaDto(
+    '#0000BB', // keyword
+    '#FF8000', // variable
+    '#fbc201', // function
+    '#007700', // string
+    '#DD0000'  // comment
+);
+
+$XMLColorSchemaDto = new XMLColorSchemaDto(
+    '#008000', // tag
+    '#7D9029', // attribute
+    '#BA2121', // string
+    '#BC7A00'  // comment
+);
 
 $myTheme = new Theme(
     'myThemeTitle',
@@ -124,5 +159,8 @@ Pull requests are welcome. For major changes, please open an issue first to disc
 
 Please make sure to update tests as appropriate.
 
+## Changelog
+See [CHANGELOG.md](./CHANGELOG.md) for a list of changes and version history.
+
 ## License
 [MIT](./LICENSE.md)
diff --git a/composer.json b/composer.json
index dc140b1..5d80088 100644
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,8 @@
         }
     ],
     "require": {
-        "php": ">=8.1"
+        "php": ">=8.1",
+        "ext-dom": "*"
     },
     "autoload": {
         "psr-4": {
@@ -31,13 +32,15 @@
     },
     "require-dev": {
         "phpunit/phpunit": "^10.2",
-        "squizlabs/php_codesniffer": "*",
+        "squizlabs/php_codesniffer": "^3.13",
         "doctrine/coding-standard": "^12.0"
     },
     "config": {
         "allow-plugins": {
             "dealerdirect/phpcodesniffer-composer-installer": true
-        }
+        },
+        "sort-packages": true,
+        "prefer-stable": true
     },
     "scripts": {
         "test": "./vendor/bin/phpunit",
diff --git a/examples/css/highlighter.css b/examples/css/highlighter.css
index 8e7c727..ac85c2c 100644
--- a/examples/css/highlighter.css
+++ b/examples/css/highlighter.css
@@ -54,3 +54,7 @@
 .code-highlighter .line-number {
     display: block;
 }
+
+.code-block-wrapper .code-block {
+    display: block;
+}
diff --git a/examples/img/scr_01.png b/examples/img/scr_01.png
new file mode 100644
index 0000000..2bb0ada
Binary files /dev/null and b/examples/img/scr_01.png differ
diff --git a/examples/index.php b/examples/index.php
index 9ceb69b..61f6bc7 100644
--- a/examples/index.php
+++ b/examples/index.php
@@ -15,7 +15,7 @@
 
 $text = '
 

PHP

-
+

 <?php
 abstract class AbstractClass
 {
@@ -48,10 +48,10 @@ public function prefixName(string $name): string
 $class = new ConcreteClass;
 echo $class->prefixName("Pacman"), "\n";
 echo $class->prefixName("Pacwoman"), "\n";
-
+

JavaScript

-
+

 // Arrow functions let us omit the `function` keyword. Here `long_example`
 // points to an anonymous function value.
 const long_example = (input1, input2) => {
@@ -65,10 +65,10 @@ public function prefixName(string $name): string
 
 long_example(2, 3); // Prints "Hello, World!" and returns 5.
 short_example(2);   // Returns 7.
-
+

Bash

-
+

 #!/bin/bash
 read -p "Enter number : " n
 if test $n -ge 0
@@ -77,10 +77,10 @@ public function prefixName(string $name): string
 else
 	echo "$n number is negative number."
 fi
-
+

Go

-
+

 package main
 
 import "fmt"
@@ -104,9 +104,9 @@ public function prefixName(string $name): string
     fmt.Scanln()
     fmt.Println("done")
 }
-
+

Xml

-
+

 
 
 
@@ -130,10 +130,10 @@ public function prefixName(string $name): string
      
    
 
-
+

HTML

-
+

 
 Title
 
@@ -150,7 +150,7 @@ function $init() {return true;}
     Mix all ingredients and knead thoroughly.
   
 
-
+
'; require_once '../vendor/autoload.php'; @@ -158,10 +158,9 @@ function $init() {return true;} use Demyanovs\PHPHighlight\Highlighter; use Demyanovs\PHPHighlight\Themes\ObsidianTheme; -$highlighter = new Highlighter($text, ObsidianTheme::TITLE); -// Configuration -$highlighter->showLineNumbers(true); -$highlighter->showActionPanel(true); +$highlighter = (new Highlighter($text, ObsidianTheme::TITLE)) + ->showLineNumbers(true) + ->showActionPanel(true); echo $highlighter->parse(); ?> diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f1b6412..b1459d8 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -28,6 +28,7 @@ + @@ -35,4 +36,5 @@ examples/ + tests/ \ No newline at end of file diff --git a/src/CodeBlockWrapper.php b/src/CodeBlockWrapper.php new file mode 100644 index 0000000..b2723b2 --- /dev/null +++ b/src/CodeBlockWrapper.php @@ -0,0 +1,103 @@ +', PHP_EOL, $highlightedCode); + $lineCount = substr_count($text, PHP_EOL) + 1; + + $wrapper = '
'; + + if ($this->showActionPanel && $filePath) { + $wrapper .= $this->buildActionPanel($filePath); + } + + $lineNumbers = ''; + if ($this->showLineNumbers && $lineCount > 1) { + $lineNumbers = $this->buildLineNumbers($lineCount); + } + + $wrapper .= sprintf( + ' +
' . + $lineNumbers . + '
%s
+
', + Styles::getCodeHighlighterStyle(), + $bgColor, + $text, + ); + + $wrapper .= '
'; + + return $wrapper; + } + + /** + * Build action panel HTML + */ + private function buildActionPanel(string $filePath): string + { + return sprintf( + ' +
+
+ %s +
+
+ ', + Styles::getCodeBlockWrapperMetaStyle(), + Styles::getCodeBlockWrapperInfoStyle(), + $filePath, + ); + } + + /** + * Build line numbers HTML + */ + private function buildLineNumbers(int $count): string + { + if ($count === 1) { + return ''; + } + + // Generate line numbers more efficiently using array_map and implode + $lineNumbers = implode('', array_map( + fn (int $i) => sprintf( + '%d', + Styles::getLineNumberStyle(), + $this->theme->defaultColorSchema->getDefaultColor(), + $i, + ), + range(1, $count), + )); + + return sprintf( + '
%s
', + Styles::getLineNumbersStyle(), + $lineNumbers, + ); + } +} diff --git a/src/Exception/InvalidLanguageException.php b/src/Exception/InvalidLanguageException.php new file mode 100644 index 0000000..d1eb86f --- /dev/null +++ b/src/Exception/InvalidLanguageException.php @@ -0,0 +1,14 @@ +` and `
` tags with code blocks,
+ * and applies syntax highlighting based on the specified language.
+ *
+ * @example
+ * ```php
+ * $text = '

+ * 
'; + * + * $highlighter = new Highlighter($text, 'obsidian'); + * echo $highlighter + * ->showLineNumbers(true) + * ->showActionPanel(true) + * ->parse(); + * ``` + * @example + * ```php + * // Using custom theme + * $customTheme = new Theme('myTheme', $defaultSchema, $phpSchema, $xmlSchema); + * $highlighter = new Highlighter($text, 'myTheme', [$customTheme]); + * ``` + * @note Limitations and Considerations: + * - Blocks without data-lang attribute use PHP highlighting by default. + * - Empty code blocks are automatically skipped + * - File paths (data-file) are sanitized and limited to 255 characters for security + * - Unknown themes fall back to default theme silently + * - For languages without specific highlighter (bash, xml, html), uses PHP highlighter + * which provides basic syntax highlighting + * - Language names are case-insensitive and normalized (e.g., 'JS' → 'javascript') + * - HTML attributes are parsed using DOMDocument with regex fallback + * - Large code blocks may impact performance + * - Line numbers are not displayed for single-line code blocks + * - Action panel only appears when both showActionPanel is true AND data-file is provided + */ class Highlighter { + private const PATTERN_PRE_TAG = '/]*)>(.*?)<\/pre>/ism'; + private const PATTERN_PRE_CODE_TAG = '/]*)>]*)>(.*?)<\/code><\/pre>/ism'; + private const PATTERN_QUOTED_ATTR = '/data-([a-zA-Z0-9_-]+)\s*=\s*(["\'])((?:(?!\2).)*)\2/'; + private const PATTERN_UNQUOTED_ATTR = '/data-([a-zA-Z0-9_-]+)\s*=\s*([^\s>]+)/'; + + public const PHP_OPEN_TAG = '` or `
` tags with code blocks
+     * @param string  $themeTitle   Theme name (defaults to 'default' if empty)
+     * @param Theme[] $customThemes Custom Theme instances to register
+     *
+     * @throws InvalidThemeException If customThemes contains non-Theme instances.
+     * @throws ThemeNotSetException If themeTitle is invalid.
      */
     public function __construct(string $text, string $themeTitle = '', array $customThemes = [])
     {
-        $this->text = str_replace('text = str_replace(self::PHP_OPEN_TAG, self::PHP_OPEN_TAG_ESCAPED, $text);
         $this->themePool = new ThemePool($customThemes);
-        $this->theme = $this->themePool->getByTitle($themeTitle);
+        $this->theme = $this->themePool->getByTitle($themeTitle ?: DefaultTheme::TITLE);
+        $this->codeBlockWrapper = new CodeBlockWrapper($this->showLineNumbers, $this->showActionPanel, $this->theme);
     }
 
     /**
-     * @return string|string[]|null
+     * Parse and highlight all code blocks in the text
+     *
+     * Finds all `
` and `
` tags and applies syntax highlighting based on `data-lang` attribute.
+     * Supports attributes: `data-lang`, `data-file`, `data-theme`
+     * Also supports `class="language-*"` attribute on `` tag (common in Markdown output)
+     *
+     * @return string Processed HTML with highlighted code blocks, or original text if no matches found
+     *
+     * @note Empty blocks are skipped. Blocks without data-lang use PHP highlighting by default.
      */
-    public function parse(): array|string|null
+    public function parse(): string
     {
-        $callback = function ($matches) {
-            $patternDataAttr = '/data-(\S+)=["\']?((?:.(?!["\']?\s+(?:\S+)=|[>"\']))+.)["\']?/ism';
-            preg_match_all($patternDataAttr, $matches[1], $attributes);
-            $data = [];
-            foreach ($attributes[1] as $key => $attr) {
-                $data[$attr] = $attributes[2][$key];
-            }
+        // First process 
 tags (more specific pattern)
+        $text = preg_replace_callback(
+            self::PATTERN_PRE_CODE_TAG,
+            [$this, 'processPreCodeTag'],
+            $this->text,
+        );
+
+        // Then process remaining 
 tags
+        return preg_replace_callback(
+            self::PATTERN_PRE_TAG,
+            [$this, 'processPreTag'],
+            $text,
+        );
+    }
 
-            $block = isset($matches[2]) ? trim($matches[2]) : '';
-            $lang  = $data['lang'] ?? '';
-            $file  = $data['file'] ?? '';
-            $themeName = $data['theme'] ?? '';
+    /**
+     * Process a single 
 tag match (common in Markdown output)
+     *
+     * @param array $matches Regex matches from preg_replace_callback
+     *
+     * @return string Processed HTML
+     */
+    private function processPreCodeTag(array $matches): string
+    {
+        $preAttributes = $matches[1] ?? '';
+        $codeAttributes = $matches[2] ?? '';
+        $block = isset($matches[3]) ? trim($matches[3]) : '';
 
-            if (!$lang) {
-                return str_replace('parseBlock($block, $lang, $file, $themeName);
-        };
+        $preData = $this->parseAttributes($preAttributes);
+        $codeData = $this->parseAttributes($codeAttributes);
 
-        $patternPreTag = '/]+)>(.*?)<\/pre>/ism';
+        $lang = $this->extractLanguageFromClass($codeData['class'] ?? '');
 
-        return preg_replace_callback(
-            $patternPreTag,
-            $callback,
-            $this->text,
-        );
+        // Prefer data-lang from 
, then from , then from class
+        $lang = $preData['lang'] ?? $codeData['lang'] ?? $lang;
+        $file = $preData['file'] ?? $codeData['file'] ?? '';
+        $themeName = $preData['theme'] ?? $codeData['theme'] ?? '';
+
+        $lang = LanguageNormalizer::normalize($lang);
+        $file = $this->sanitizeFilePath($file);
+
+        if (empty($lang)) {
+            $lang = 'php';
+        }
+
+        return $this->parseBlock($block, $lang, $file, $themeName);
     }
 
-    private function parseBlock(string $block, string $lang, string $filePath = '', string $themeName = ''): string
+    /**
+     * Extract language from class attribute (e.g., "language-php" -> "php")
+     */
+    private function extractLanguageFromClass(string $class): string
     {
-        if ($lang === 'bash') {
-            $highlighter = HighlighterBash::getInstance($block);
-        } elseif ($lang === 'xml' || $lang === 'html') {
-            $highlighter = HighlighterXML::getInstance($block);
-        } else {
-            $block = str_replace('<?php', 'themePool->getByTitle($themeName);
-        } else {
-            $theme = $this->theme;
+        return '';
+    }
+
+    /**
+     * Process a single 
 tag match
+     *
+     * @param array $matches Regex matches from preg_replace_callback
+     */
+    private function processPreTag(array $matches): string
+    {
+        $attributesString = $matches[1] ?? '';
+        $block = isset($matches[2]) ? trim($matches[2]) : '';
+
+        if (empty($block)) {
+            return '';
         }
 
-        $theme->PHPColorSchemaDto->applyColors();
-        $highlighter->setTheme($theme);
-        $block = $highlighter->highlight();
+        $data = $this->parseAttributes($attributesString);
+        $lang = LanguageNormalizer::normalize($data['lang'] ?? '');
+        $file = $this->sanitizeFilePath($data['file'] ?? '');
+        $themeName = $data['theme'] ?? '';
+
+        if (empty($lang)) {
+            $lang = 'php';
+        }
 
-        return $this->wrapCode($block, $theme->defaultColorSchema->getBackgroundColor(), $filePath);
+        return $this->parseBlock($block, $lang, $file, $themeName);
     }
 
-    private function wrapCode(string $text, string $bgColor = '', string $filePath = ''): string
+    /**
+     * Parse HTML attributes from a string using DOMDocument for reliability
+     *
+     * @return array Associative array of attribute names and values
+     */
+    private function parseAttributes(string $attributesString): array
     {
-        $wrapper = '
'; - if ($this->showActionPanel && $filePath) { - $wrapper .= $this->attachActionPanel($filePath); + if (empty(trim($attributesString))) { + return []; } - $lineNumbers = ''; - $text = str_replace('
', PHP_EOL, $text); + // Try to use DOMDocument for reliable parsing + try { + // Wrap in a temporary tag to make it valid HTML + $html = '
'; - if ($this->showLineNumbers) { - $lineNumbers = $this->attachLineNumbers(count(explode(PHP_EOL, $text))); - } + // Suppress warnings for invalid HTML + $previousErrorReporting = error_reporting(E_ALL & ~E_WARNING); + $dom = new \DOMDocument(); + $loaded = @$dom->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + error_reporting($previousErrorReporting); - $wrapper .= sprintf( - ' -
' . - $lineNumbers . - '
%s
-
', - Styles::getCodeHighlighterStyle(), - $bgColor, - $text, - ); + if ($loaded) { + $xpath = new \DOMXPath($dom); + $nodes = $xpath->query('//div[@*]'); + + if ($nodes->length > 0) { + $node = $nodes->item(0); + if ($node instanceof \DOMElement) { + $attributes = []; + foreach ($node->attributes as $attr) { + $name = $attr->nodeName; + $value = $attr->nodeValue; + // Extract data-* attributes and class attribute (for language detection) + if (str_starts_with($name, 'data-')) { + $attributes[substr($name, 5)] = $value; + } elseif ($name === 'class') { + $attributes['class'] = $value; + } + } - $wrapper .= '
'; + return $attributes; + } + } + } + } catch (\Throwable) { + // Fall through to regex fallback + } - return $wrapper; + // Fallback to regex parsing if DOMDocument fails + return $this->parseAttributesWithRegex($attributesString); } - public function showLineNumbers(bool $showLineNumbers): void + /** + * Fallback method to parse attributes using regex + * + * @return array Associative array of attribute names and values + */ + private function parseAttributesWithRegex(string $attributesString): array { - $this->showLineNumbers = $showLineNumbers; + $data = []; + + if (preg_match_all(self::PATTERN_QUOTED_ATTR, $attributesString, $quotedMatches, PREG_SET_ORDER)) { + foreach ($quotedMatches as $match) { + $attrName = $match[1]; + $attrValue = $match[3]; + $data[$attrName] = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + // Pattern for unquoted attributes: data-lang=php + // Only match if not already found in quoted matches + if (preg_match_all(self::PATTERN_UNQUOTED_ATTR, $attributesString, $unquotedMatches, PREG_SET_ORDER)) { + foreach ($unquotedMatches as $match) { + $attrName = $match[1]; + // Only add if not already parsed as quoted attribute + if (!isset($data[$attrName])) { + $attrValue = $match[2]; + $data[$attrName] = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + } + + // Also extract class attribute for language detection (e.g., class="language-xml") + if (preg_match('/class\s*=\s*(["\'])((?:(?!\1).)*)\1/', $attributesString, $classMatches)) { + $data['class'] = $classMatches[2]; + } elseif (preg_match('/class\s*=\s*([^\s>]+)/', $attributesString, $classMatches)) { + $data['class'] = html_entity_decode($classMatches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + return $data; } - public function showActionPanel(bool $showActionPanel): void + /** + * Parse and highlight a code block + * + * @throws InvalidLanguageException If language is invalid. + */ + private function parseBlock(string $block, string $lang, string $filePath = '', string $themeName = ''): string { - $this->showActionPanel = $showActionPanel; + $trimmedBlock = trim($block); + if (empty($trimmedBlock)) { + return ''; + } + + $lang = LanguageNormalizer::normalize($lang); + if (empty($lang)) { + throw new InvalidLanguageException('Language cannot be empty'); + } + + $theme = $this->getTheme($themeName); + + $highlighter = $this->createHighlighter($lang, $trimmedBlock); + + $theme->PHPColorSchemaDto->applyColors(); + $highlighter->setTheme($theme); + $highlightedBlock = $highlighter->highlight(); + + if ($theme !== $this->theme) { + $this->codeBlockWrapper = new CodeBlockWrapper($this->showLineNumbers, $this->showActionPanel, $theme); + } + + return $this->codeBlockWrapper->wrap($highlightedBlock, $theme->defaultColorSchema->getBackgroundColor(), $filePath); } - private function attachActionPanel(string $filePath): string + /** + * Create appropriate highlighter instance based on language + */ + private function createHighlighter(string $lang, string $block): HighlighterBase { - return sprintf( - ' -
-
- %s -
-
- ', - Styles::getCodeBlockWrapperMetaStyle(), - Styles::getCodeBlockWrapperInfoStyle(), - $filePath, - ); + return HighlighterFactory::create($lang, $block); } - private function attachLineNumbers(int $count): string + /** + * Get theme instance (use override if provided, otherwise use default) + */ + private function getTheme(string $themeName): Theme { - if ($count === 1) { - return false; + if (!empty($themeName)) { + try { + return $this->themePool->getByTitle($themeName); + } catch (ThemeNotSetException) { + return $this->theme; + } } - $lineNumbers = ''; - for ($i = 1; $i < $count + 1; $i++) { - $lineNumbers .= sprintf( - '%d', - Styles::getLineNumberStyle(), - $this->theme->defaultColorSchema->getDefaultColor(), - $i, - ); + return $this->theme; + } + + /** + * Sanitize file path to prevent XSS attacks + */ + private function sanitizeFilePath(string $filePath): string + { + $sanitized = htmlspecialchars($filePath, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if (strlen($sanitized) > 255) { + $sanitized = substr($sanitized, 0, 252) . '...'; } - return sprintf( - '
%s
', - Styles::getLineNumbersStyle(), - $lineNumbers, - ); + return $sanitized; + } + + /** + * Enable or disable line numbers display + * + * @note Line numbers are not shown for single-line code blocks + */ + public function showLineNumbers(bool $showLineNumbers): self + { + $this->showLineNumbers = $showLineNumbers; + $this->codeBlockWrapper = new CodeBlockWrapper($this->showLineNumbers, $this->showActionPanel, $this->theme); + + return $this; + } + + /** + * Enable or disable action panel display + * Shows file path from data-file attribute at the top of code block. + * + * @note Action panel only appears if data-file attribute is present + */ + public function showActionPanel(bool $showActionPanel): self + { + $this->showActionPanel = $showActionPanel; + $this->codeBlockWrapper = new CodeBlockWrapper($this->showLineNumbers, $this->showActionPanel, $this->theme); + + return $this; } } diff --git a/src/HighlighterBase.php b/src/HighlighterBase.php index 7458ea2..a229aa0 100644 --- a/src/HighlighterBase.php +++ b/src/HighlighterBase.php @@ -2,10 +2,25 @@ namespace Demyanovs\PHPHighlight; +use Demyanovs\PHPHighlight\Exception\ThemeNotSetException; use Demyanovs\PHPHighlight\Themes\Theme; -class HighlighterBase +/** + * Base class for syntax highlighters + * + * Provides common functionality for highlighting code blocks: + * - Token extraction and coloring + * - Keyword, variable, and flag detection + * - Comment line handling + * - Theme integration + * + * @note This is an abstract class and cannot be instantiated directly. + * Use concrete implementations like HighlighterPHP, HighlighterBash, etc. + */ +abstract class HighlighterBase implements HighlighterInterface { + private const PATTERN_TOKENS = '/\$[a-zA-Z_][a-zA-Z0-9_]*|-[a-zA-Z0-9-]+|\b[a-zA-Z_][a-zA-Z0-9_]*\b/'; + /** @var string[] */ protected array $keywords = []; @@ -15,39 +30,46 @@ public function __construct(protected string $text) { } + /** + * Set the theme to use for highlighting + */ public function setTheme(Theme $theme): void { $this->theme = $theme; } + /** + * Highlight the code text with syntax coloring + * + * Colors keywords, variables (starting with $), flags (starting with -), and comment lines (starting with #). + * + * @throws ThemeNotSetException If theme is not set before highlighting. + * + * @note Empty text returns empty string. Theme must be set before calling. + */ public function highlight(): string { + if (!isset($this->theme)) { + throw new ThemeNotSetException(); + } + + $trimmedText = trim($this->text); + if (empty($trimmedText)) { + return ''; + } + $byLines = explode(PHP_EOL, $this->text); - $lines = []; + $lines = []; + foreach ($byLines as $key => $line) { - // Comment line + // Comment line - process entire line as comment if ($this->isCommentLine($line)) { - $lines[$key] = self::colorWord($line, $line, $this->theme->defaultColorSchema->getCommentColor()); + $lines[$key] = self::wrapWithColor($line, $this->theme->defaultColorSchema->getCommentColor()); continue; } - $words = array_unique(explode(' ', $line)); - foreach ($words as $word) { - $word = trim($word); - if ($this->isKeyword($word)) { - $line = self::colorWord($word, $line, $this->theme->defaultColorSchema->getKeywordColor()); - } elseif ($this->isFlag($word)) { - $line = self::colorWord($word, $line, $this->theme->defaultColorSchema->getFlagColor()); - } elseif ($this->isVariable($word)) { - $line = self::colorWord($word, $line, $this->theme->defaultColorSchema->getVariableColor()); - } - -// else { -// $line = self::colorWord($word, $line, $this->theme->defaultColorSchema->getStringColor()); -// } - - $lines[$key] = $line; - } + $processedLine = $this->processLine($line); + $lines[$key] = $processedLine; } return sprintf( @@ -58,39 +80,169 @@ public function highlight(): string } /** - * @return array|string|string[] + * Process a single line of code with improved highlighting logic */ - public static function colorWord(string $word, string $line, string $color): array|string + private function processLine(string $line): string { - return str_replace( - $word, - sprintf('%s', $color, $word), - $line, - ); + // Extract all potential tokens (words, variables, flags) with their positions + $tokens = $this->extractTokens($line); + + if (empty($tokens)) { + return $line; + } + + // Sort tokens by position (ascending order) to process from start to end + usort($tokens, static fn ($a, $b) => $a['position'] <=> $b['position']); + + // Build result by processing tokens and keeping track of processed positions + $result = ''; + $lastPosition = 0; + $processedPositions = []; + + foreach ($tokens as $token) { + $position = $token['position']; + $length = $token['length']; + $text = $token['text']; + + // Skip if this position overlaps with already processed area + if ($this->isPositionProcessed($position, $length, $processedPositions)) { + continue; + } + + // Add text before this token + if ($position > $lastPosition) { + $result .= substr($line, $lastPosition, $position - $lastPosition); + } + + $color = null; + + // Determine color based on token type (optimized order: most common first) + if ($this->isVariable($text)) { + $color = $this->theme->defaultColorSchema->getVariableColor(); + } elseif ($this->isKeyword($text)) { + $color = $this->theme->defaultColorSchema->getKeywordColor(); + } elseif ($this->isFlag($text)) { + $color = $this->theme->defaultColorSchema->getFlagColor(); + } + + if ($color !== null) { + // Add colored token + $result .= self::wrapWithColor($text, $color); + // Mark this position as processed + $processedPositions[] = ['start' => $position, 'end' => $position + $length]; + } else { + // Add token as-is if no color + $result .= $text; + } + + $lastPosition = $position + $length; + } + + // Add remaining text after last token + if ($lastPosition < strlen($line)) { + $result .= substr($line, $lastPosition); + } + + return $result; } + /** + * Extract tokens (words, variables, flags) from a line with their positions + * + * @return array Array of tokens with positions + */ + private function extractTokens(string $line): array + { + $tokens = []; + + if (preg_match_all(self::PATTERN_TOKENS, $line, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $match) { + $text = $match[0]; + $position = $match[1]; + + if (empty($text)) { + continue; + } + + $tokens[] = [ + 'text' => $text, + 'position' => $position, + 'length' => strlen($text), // Position is in bytes, so use strlen + ]; + } + } + + return $tokens; + } + + /** + * Check if a position in the line has already been processed + * + * @param int $position Start position + * @param int $length Length of the token + * @param array $processedPositions Array of processed positions + * + * @return bool True if position overlaps with already processed area + */ + private function isPositionProcessed(int $position, int $length, array $processedPositions): bool + { + $end = $position + $length; + + foreach ($processedPositions as $processed) { + // Check if current position overlaps with processed area + if (!($end <= $processed['start'] || $position >= $processed['end'])) { + return true; + } + } + + return false; + } + + /** + * Wrap text with color span + */ + private static function wrapWithColor(string $text, string $color): string + { + return sprintf('%s', $color, htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + + /** + * Update the text to be highlighted + */ public function setText(string $text): void { $this->text = $text; } + /** + * Check if a word is a variable (starts with $) + */ protected function isVariable(string $word): bool { - return str_starts_with($word, '$') ?? false; + return str_starts_with($word, '$'); } + /** + * Check if a word is a flag (starts with -) + */ protected function isFlag(string $word): bool { - return str_starts_with($word, '-') ?? false; + return str_starts_with($word, '-'); } + /** + * Check if a word is a keyword (defined in $keywords array) + */ protected function isKeyword(string $word): bool { - return in_array($word, $this->keywords) ?? false; + return in_array($word, $this->keywords, true); } + /** + * Check if a line is a comment line (starts with #) + */ protected function isCommentLine(string $word): bool { - return str_starts_with($word, '#') ?? false; + return str_starts_with($word, '#'); } } diff --git a/src/HighlighterBash.php b/src/HighlighterBash.php index 11da8d9..d155272 100644 --- a/src/HighlighterBash.php +++ b/src/HighlighterBash.php @@ -2,10 +2,11 @@ namespace Demyanovs\PHPHighlight; +/** + * Highlighter for Bash shell scripts + */ class HighlighterBash extends HighlighterBase { - private static ?self $instance = null; - /** @var string[] */ protected array $keywords = [ 'wget', @@ -32,15 +33,4 @@ class HighlighterBash extends HighlighterBase 'require', 'composer', ]; - - public static function getInstance(string $text): self - { - if (self::$instance) { - self::$instance->setText($text); - - return self::$instance; - } - - return self::$instance = new self($text); - } } diff --git a/src/HighlighterFactory.php b/src/HighlighterFactory.php new file mode 100644 index 0000000..41dfed7 --- /dev/null +++ b/src/HighlighterFactory.php @@ -0,0 +1,32 @@ + new HighlighterBash($block), + 'xml', 'html' => new HighlighterXML($block), + default => self::createPHPHighlighter($block), // Use base logic for all other languages + }; + } + + /** + * Create PHP highlighter (used for PHP, JavaScript, Go, etc.) + */ + private static function createPHPHighlighter(string $block): HighlighterPHP + { + // Restore PHP tags if they were escaped + $block = str_replace(Highlighter::PHP_OPEN_TAG_ESCAPED, Highlighter::PHP_OPEN_TAG, $block); + + return new HighlighterPHP($block); + } +} diff --git a/src/HighlighterInterface.php b/src/HighlighterInterface.php new file mode 100644 index 0000000..a6f5fe7 --- /dev/null +++ b/src/HighlighterInterface.php @@ -0,0 +1,23 @@ +', '
']; - public static function getInstance(string $text): self + public function highlight(): string { - if (self::$instance) { - self::$instance->setText($text); + // Use PHP's built-in highlight_string function + $highlighted = highlight_string('text), true); - return self::$instance; - } + // Remove unwanted parts from highlight_string output + $text = str_replace(self::UNWANTED_PATTERNS, '', $highlighted); + // Remove tags with any attributes + $text = preg_replace('/]*>/', '', $text); + + // Normalize line breaks + $text = str_replace(PHP_EOL, '
', $text); + $byLines = explode('
', $text); - return self::$instance = new self($text); + // Clean up highlight_string output: remove first wrapper line and trailing closing tags + $lines = $this->cleanHighlightOutput($byLines); + + return implode('
', $lines); } - public function highlight(): string + /** + * Clean up highlight_string output by removing wrapper elements + * + * @param array $byLines Lines from highlight_string + * + * @return array Cleaned lines + */ + private function cleanHighlightOutput(array $byLines): array { - $text = str_replace( - ['<?php ', '', ''], - '', - highlight_string('text), true), - ); - $text = str_replace(PHP_EOL, '
', $text); + $lines = []; + $totalLines = count($byLines); - $byLines = explode('
', $text); - $lines = []; - $i = 0; - foreach ($byLines as $key => $line) { - $i++; - if ($i === 1) { - continue; + // Find the range of lines to process (skip leading/trailing empty lines from highlight_string) + $firstLineIndex = 0; + $lastLineIndex = $totalLines - 1; + + // Skip leading empty lines before
 tag
+        for ($i = 0; $i < $totalLines; $i++) {
+            $trimmed = trim($byLines[$i]);
+            if (str_starts_with($trimmed, '' &&
-                $byLines[count($byLines) - 1] === ''
-            ) {
-                $lines[] = $byLines[count($byLines) - 4];
-                $lines[] = $byLines[$i] . $byLines[count($byLines) - 2] . $byLines[count($byLines) - 1];
+        // Skip trailing empty lines after 
tag + for ($i = $totalLines - 1; $i >= 0; $i--) { + $trimmed = trim($byLines[$i]); + if (str_ends_with($trimmed, '
')) { + $lastLineIndex = $i; break; } - $lines[$key] = $line; + if (!empty($trimmed)) { + $lastLineIndex = $i; + break; + } } - return implode('
', $lines); + // Helper function to check if a line is empty (including HTML tags only) + $isEmptyLine = static function (string $line): bool { + // Remove all HTML tags and check if remaining content is empty + $content = strip_tags($line); + + return trim($content) === ''; + }; + + for ($i = $firstLineIndex; $i <= $lastLineIndex; $i++) { + $line = $byLines[$i]; + $trimmed = trim($line); + $wasPreTagLine = false; + $wasClosingPreTagLine = false; + + // Remove
 opening tag from first processed line
+            if ($i === $firstLineIndex && str_starts_with($trimmed, ']*>/', '', $line);
+                $trimmed = trim($line);
+            }
+
+            // Remove 
closing tag from last processed line + if ($i === $lastLineIndex && str_ends_with($trimmed, '
')) { + $wasClosingPreTagLine = true; + $line = preg_replace('/<\/pre>$/', '', $line); + $trimmed = trim($line); + } + + // Skip lines that became empty only after removing wrapper tags + // These are artifacts from highlight_string, not real empty lines from the code + // Check if line is empty (including HTML tags only) + if ($isEmptyLine($line)) { + if ($wasPreTagLine || $wasClosingPreTagLine) { + // Skip this line - it's just a wrapper tag artifact + continue; + } + + // Preserve real empty lines from the original code + $lines[] = ''; + continue; + } + + $lines[] = $line; + } + + // Remove leading and trailing empty lines that are artifacts from highlight_string + while (!empty($lines) && $isEmptyLine($lines[0])) { + array_shift($lines); + } + + while (!empty($lines) && $isEmptyLine($lines[count($lines) - 1])) { + array_pop($lines); + } + + return $lines; } } diff --git a/src/HighlighterXML.php b/src/HighlighterXML.php index 617c908..42bf691 100644 --- a/src/HighlighterXML.php +++ b/src/HighlighterXML.php @@ -2,57 +2,135 @@ namespace Demyanovs\PHPHighlight; +/** + * Highlighter for XML and HTML markup + */ class HighlighterXML extends HighlighterBase { - private static ?self $instance = null; + private const PATTERN_XML_TAG = '#<(?!\?)([/!]*?)(.*?)([\s]*?)>#sU'; + private const PATTERN_XML_INFO = '#<([\?])(.*?)([\?])>#sU'; + private const PATTERN_XML_ATTR = "#([^\s=]*?)\s*=\s*("|')(.*?)(\\2)#isU"; - public static function getInstance(string $text): self + public function highlight(): string { - if (self::$instance) { - self::$instance->setText($text); + $normalized = str_replace(["\r\n", "\r"], "\n", $this->text); + $byLines = explode("\n", $normalized); + $lines = []; - return self::$instance; + foreach ($byLines as $line) { + $processedLine = $this->processLine($line); + $lines[] = $processedLine; } - return self::$instance = new self($text); + return implode('
', $lines); } - public function highlight(): string + /** + * Process a single line of XML/HTML + */ + private function processLine(string $line): string { - $text = htmlspecialchars($this->text); - // Brackets - $text = preg_replace( - '#<([/]*?)(.*)([\s]*?)>#sU', - sprintf( - '<\\1\\2\\3>', - $this->theme->XMLColorSchemaDto->getXMLTagColor(), - ), - $text, - ); - // Xml version - $text = preg_replace( - '#<([\?])(.*)([\?])>#sU', - sprintf( - '<\\1\\2\\3>', - $this->theme->XMLColorSchemaDto->getXMLInfoColor(), - ), - $text, - ); - // Attributes - $text = preg_replace( - "#([^\s]*?)\=("|')(.*)("|')#isU", - sprintf( - '\\1=\\2\\3\\4', - $this->theme->XMLColorSchemaDto->getXMLAttrNameColor(), - $this->theme->XMLColorSchemaDto->getXMLAttrValueColor(), - ), + if (trim($line) === '') { + return ''; + } + + // First escape HTML + $text = htmlspecialchars($line, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $defaultColor = $this->theme->defaultColorSchema->getStringColor(); + + // Process XML version/processing instructions first (more specific pattern) + // Use callback to process attributes inside XML info + $text = preg_replace_callback( + self::PATTERN_XML_INFO, + function ($matches) { + // Process attributes inside XML info + $infoContent = $matches[2]; + $infoContent = preg_replace( + self::PATTERN_XML_ATTR, + sprintf( + '\\1=\\2\\3\\4', + $this->theme->XMLColorSchemaDto->getXMLAttrNameColor(), + $this->theme->XMLColorSchemaDto->getXMLAttrValueColor(), + ), + $infoContent, + ); + + return sprintf( + '<%s%s%s>', + $this->theme->XMLColorSchemaDto->getXMLInfoColor(), + $matches[1], + $infoContent, + $matches[3], + ); + }, $text, ); - return sprintf( - '%s', - $this->theme->defaultColorSchema->getStringColor(), + // Process XML tags (but skip if already processed as info) + // PATTERN_XML_TAG already excludes \\1=\\2\\3\\4', + $this->theme->XMLColorSchemaDto->getXMLAttrNameColor(), + $this->theme->XMLColorSchemaDto->getXMLAttrValueColor(), + ), + $tagContent, + ); + + return sprintf( + '<%s%s%s>', + $this->theme->XMLColorSchemaDto->getXMLTagColor(), + $matches[1], + $tagContent, + $matches[3], + ); + }, $text, ); + + // Wrap remaining text content (non-highlighted parts) with default color + // Split by highlighted spans and wrap non-span parts + // Use non-greedy matching to avoid issues with nested spans + $parts = preg_split('/(]*>.*?<\/span>)/s', $text, -1, PREG_SPLIT_DELIM_CAPTURE); + $result = ''; + + foreach ($parts as $part) { + if ($part === '') { + continue; + } + + // If it's already a span, keep it as-is + if (preg_match('/^]*>/', $part)) { + $result .= $part; + } else { + // Only wrap non-empty, non-whitespace content + $trimmed = trim($part); + if (!empty($trimmed)) { + $result .= sprintf('%s', $defaultColor, $part); + } else { + // Keep whitespace as-is + $result .= $part; + } + } + } + + // If result is empty but text contains spans, return text as-is + // Otherwise, if result is empty, wrap entire text with default color + if ($result === '') { + // Check if text already contains spans (was processed) + if (preg_match('/]*>/', $text)) { + return $text; + } + + return sprintf('%s', $defaultColor, $text); + } + + return $result; } } diff --git a/src/LanguageNormalizer.php b/src/LanguageNormalizer.php new file mode 100644 index 0000000..e59f027 --- /dev/null +++ b/src/LanguageNormalizer.php @@ -0,0 +1,30 @@ + 'javascript', + 'htm' => 'html', + 'go-lang' => 'go', + 'sh' => 'bash', + ]; + + /** + * Normalize language identifier + * + * @param string $lang Language identifier + * + * @return string Normalized language identifier + */ + public static function normalize(string $lang): string + { + $lang = strtolower(trim($lang)); + + return self::LANGUAGE_ALIASES[$lang] ?? $lang; + } +} diff --git a/src/Themes/Exception/UnknownThemeException.php b/src/Themes/Exception/UnknownThemeException.php deleted file mode 100644 index d3216cb..0000000 --- a/src/Themes/Exception/UnknownThemeException.php +++ /dev/null @@ -1,7 +0,0 @@ -themes as $theme) { - if ($theme->getTitle() === $title) { - $theme->PHPColorSchemaDto->applyColors(); - - return $theme; - } + if (!isset($this->themes[$title])) { + throw new ThemeNotSetException(); } - throw new UnknownThemeException(sprintf('Unknown theme: %s', $title)); + $theme = $this->themes[$title]; + $theme->PHPColorSchemaDto->applyColors(); + + return $theme; } private function addDefaultThemes(): void @@ -63,12 +62,7 @@ private function addDefaultThemes(): void private function addCustomThemes(array $customThemes): void { foreach ($customThemes as $customTheme) { - $this->themes[] = new $customTheme( - $customTheme->getTitle(), - $customTheme->defaultColorSchema, - $customTheme->PHPColorSchemaDto, - $customTheme->XMLColorSchemaDto, - ); + $this->themes[$customTheme->getTitle()] = $customTheme; } } } diff --git a/tests/CodeBlockWrapperTest.php b/tests/CodeBlockWrapperTest.php new file mode 100644 index 0000000..9b1d2b0 --- /dev/null +++ b/tests/CodeBlockWrapperTest.php @@ -0,0 +1,118 @@ +theme = new DefaultTheme(); + } + + public function testWrapWithLineNumbers(): void + { + $wrapper = new CodeBlockWrapper(true, false, $this->theme); + $highlightedCode = 'line1
line2
line3'; + $output = $wrapper->wrap($highlightedCode, '#ffffff'); + + $this->assertStringContainsString('line-numbers', $output); + $this->assertStringContainsString('line-number', $output); + $this->assertStringContainsString('>1', $output); + $this->assertStringContainsString('>2', $output); + $this->assertStringContainsString('>3', $output); + } + + public function testWrapWithoutLineNumbers(): void + { + $wrapper = new CodeBlockWrapper(false, false, $this->theme); + $highlightedCode = 'line1
line2'; + $output = $wrapper->wrap($highlightedCode, '#ffffff'); + + $this->assertStringNotContainsString('line-numbers', $output); + $this->assertStringNotContainsString('line-number', $output); + } + + public function testWrapWithActionPanel(): void + { + $wrapper = new CodeBlockWrapper(false, true, $this->theme); + $highlightedCode = 'echo "test";'; + $filePath = 'test.php'; + $output = $wrapper->wrap($highlightedCode, '#ffffff', $filePath); + + $this->assertStringContainsString('meta', $output); + $this->assertStringContainsString('info', $output); + $this->assertStringContainsString($filePath, $output); + } + + public function testWrapWithoutActionPanel(): void + { + $wrapper = new CodeBlockWrapper(false, false, $this->theme); + $highlightedCode = 'echo "test";'; + $filePath = 'test.php'; + $output = $wrapper->wrap($highlightedCode, '#ffffff', $filePath); + + $this->assertStringNotContainsString('meta', $output); + $this->assertStringNotContainsString('info', $output); + $this->assertStringNotContainsString($filePath, $output); + } + + public function testWrapWithoutFilePathDoesNotShowActionPanel(): void + { + $wrapper = new CodeBlockWrapper(false, true, $this->theme); + $highlightedCode = 'echo "test";'; + $output = $wrapper->wrap($highlightedCode, '#ffffff', ''); + + $this->assertStringNotContainsString('meta', $output); + } + + public function testWrapSingleLineDoesNotShowLineNumbers(): void + { + $wrapper = new CodeBlockWrapper(true, false, $this->theme); + $highlightedCode = 'single line'; + $output = $wrapper->wrap($highlightedCode, '#ffffff'); + + $this->assertStringNotContainsString('line-numbers', $output); + } + + public function testWrapIncludesCodeBlockWrapper(): void + { + $wrapper = new CodeBlockWrapper(false, false, $this->theme); + $highlightedCode = 'echo "test";'; + $output = $wrapper->wrap($highlightedCode, '#ffffff'); + + $this->assertStringContainsString('code-block-wrapper', $output); + $this->assertStringContainsString('code-highlighter', $output); + $this->assertStringContainsString('code-block', $output); + } + + public function testWrapIncludesBackgroundColor(): void + { + $wrapper = new CodeBlockWrapper(false, false, $this->theme); + $highlightedCode = 'echo "test";'; + $bgColor = '#123456'; + $output = $wrapper->wrap($highlightedCode, $bgColor); + + $this->assertStringContainsString('background-color: ' . $bgColor, $output); + } + + public function testWrapConvertsBrToNewlines(): void + { + $wrapper = new CodeBlockWrapper(false, false, $this->theme); + $highlightedCode = 'line1
line2'; + $output = $wrapper->wrap($highlightedCode, '#ffffff'); + + // After wrapping,
should be converted to PHP_EOL + $this->assertStringContainsString('line1', $output); + $this->assertStringContainsString('line2', $output); + } +} diff --git a/tests/HighlighterBaseTest.php b/tests/HighlighterBaseTest.php new file mode 100644 index 0000000..e8f7753 --- /dev/null +++ b/tests/HighlighterBaseTest.php @@ -0,0 +1,55 @@ +expectException(ThemeNotSetException::class); + $this->expectExceptionMessage('Theme must be set before highlighting'); + + $highlighter->highlight(); + } + + public function testHighlightWithThemeWorks(): void + { + $highlighter = new HighlighterBash('echo "test"'); + $theme = new DefaultTheme(); + $highlighter->setTheme($theme); + + $result = $highlighter->highlight(); + + $this->assertIsString($result); + $this->assertNotEmpty($result); + } + + public function testHighlightEmptyTextReturnsEmpty(): void + { + $highlighter = new HighlighterBash(''); + $theme = new DefaultTheme(); + $highlighter->setTheme($theme); + + $result = $highlighter->highlight(); + + $this->assertEquals('', $result); + } + + public function testHighlightWhitespaceOnlyReturnsEmpty(): void + { + $highlighter = new HighlighterBash(' '); + $theme = new DefaultTheme(); + $highlighter->setTheme($theme); + + $result = $highlighter->highlight(); + + $this->assertEquals('', $result); + } +} diff --git a/tests/HighlighterFactoryTest.php b/tests/HighlighterFactoryTest.php new file mode 100644 index 0000000..62f6239 --- /dev/null +++ b/tests/HighlighterFactoryTest.php @@ -0,0 +1,71 @@ +assertInstanceOf(HighlighterBash::class, $highlighter); + $this->assertInstanceOf(HighlighterBase::class, $highlighter); + } + + public function testCreateXmlHighlighter(): void + { + $highlighter = HighlighterFactory::create('xml', ''); + + $this->assertInstanceOf(HighlighterXML::class, $highlighter); + $this->assertInstanceOf(HighlighterBase::class, $highlighter); + } + + public function testCreateHtmlHighlighter(): void + { + $highlighter = HighlighterFactory::create('html', '
'); + + $this->assertInstanceOf(HighlighterXML::class, $highlighter); + $this->assertInstanceOf(HighlighterBase::class, $highlighter); + } + + public function testCreatePhpHighlighterForPhp(): void + { + $highlighter = HighlighterFactory::create('php', 'echo "test";'); + + $this->assertInstanceOf(HighlighterPHP::class, $highlighter); + $this->assertInstanceOf(HighlighterBase::class, $highlighter); + } + + public function testCreatePhpHighlighterForUnknownLanguage(): void + { + $highlighter = HighlighterFactory::create('python', 'print("test")'); + + $this->assertInstanceOf(HighlighterPHP::class, $highlighter); + $this->assertInstanceOf(HighlighterBase::class, $highlighter); + } + + public function testCreatePhpHighlighterForJavaScript(): void + { + $highlighter = HighlighterFactory::create('javascript', 'console.log("test");'); + + $this->assertInstanceOf(HighlighterPHP::class, $highlighter); + $this->assertInstanceOf(HighlighterBase::class, $highlighter); + } + + public function testCreatePhpHighlighterRestoresEscapedPhpTags(): void + { + $code = '<?php echo "test";'; + $highlighter = HighlighterFactory::create('php', $code); + + $this->assertInstanceOf(HighlighterPHP::class, $highlighter); + // The highlighter should have restored the PHP tags internally + $this->assertInstanceOf(HighlighterBase::class, $highlighter); + } +} diff --git a/tests/HighlighterTest.php b/tests/HighlighterTest.php index e776c8f..64975c0 100644 --- a/tests/HighlighterTest.php +++ b/tests/HighlighterTest.php @@ -2,9 +2,15 @@ namespace Demyanovs\PHPHighlight\Tests; +use Demyanovs\PHPHighlight\Exception\InvalidThemeException; +use Demyanovs\PHPHighlight\Exception\ThemeNotSetException; use Demyanovs\PHPHighlight\Highlighter; -use Demyanovs\PHPHighlight\Themes\Exception\UnknownThemeException; +use Demyanovs\PHPHighlight\Themes\DefaultTheme; use Demyanovs\PHPHighlight\Themes\ObsidianTheme; +use Demyanovs\PHPHighlight\Themes\Theme; +use Demyanovs\PHPHighlight\Themes\Dto\DefaultColorSchemaDto; +use Demyanovs\PHPHighlight\Themes\Dto\PHPColorSchemaDto; +use Demyanovs\PHPHighlight\Themes\Dto\XMLColorSchemaDto; use PHPUnit\Framework\TestCase; class HighlighterTest extends TestCase @@ -20,8 +26,12 @@ public function testParseRowsNumbersDisplayed(): void { $output = $this->highlighter->parse(); + // Check that line numbers are displayed (check for any line number) + $this->assertStringContainsString('line-number', $output); + $this->assertStringContainsString('line-numbers', $output); + // Check for a specific line number that should exist (line 1) $this->assertStringContainsString( - '32', + '1', $output, ); } @@ -31,10 +41,9 @@ public function testParseRowsNumbersNotDisplayed(): void $this->highlighter->showLineNumbers(false); $output = $this->highlighter->parse(); - $this->assertStringNotContainsString( - '32', - $output, - ); + // Check that line numbers are not displayed + $this->assertStringNotContainsString('line-number', $output); + $this->assertStringNotContainsString('line-numbers', $output); } public function testParseActionPanelDisplayed(): void @@ -60,11 +69,454 @@ public function testParseActionPanelNotDisplayed(): void public function testDefaultThemeForNonExistentTitle(): void { - $this->expectException(UnknownThemeException::class); + $this->expectException(ThemeNotSetException::class); new Highlighter($this->getText(), 'nonexistent'); } + // Edge Cases Tests + + public function testEmptyCodeBlock(): void + { + $text = '
';
+        $highlighter = new Highlighter($text);
+        $output = $highlighter->parse();
+
+        $this->assertStringNotContainsString('code-block-wrapper', $output);
+    }
+
+    public function testWhitespaceOnlyBlock(): void
+    {
+        $text = '
   
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringNotContainsString('code-block-wrapper', $output); + } + + public function testBlockWithoutLanguage(): void + { + $text = '
echo "test";
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + // Blocks without language should use PHP highlighting by default + $this->assertStringContainsString('echo', $output); + $this->assertStringContainsString('test', $output); + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testPreCodeTag(): void + { + $text = '
echo "test";
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + // Should support
 pattern (common in Markdown)
+        $this->assertStringContainsString('echo', $output);
+        $this->assertStringContainsString('test', $output);
+        $this->assertStringContainsString('code-block-wrapper', $output);
+    }
+
+    public function testPreCodeTagWithDataLang(): void
+    {
+        $text = '
echo "test";
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + // Should prefer data-lang from
 tag
+        $this->assertStringContainsString('echo', $output);
+        $this->assertStringContainsString('code-block-wrapper', $output);
+    }
+
+    public function testPreCodeTagWithLanguageClass(): void
+    {
+        $text = '
console.log("test");
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + // Should extract language from class="language-*" attribute + $this->assertStringContainsString('console', $output); + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testBlockWithSpecialCharacters(): void + { + $text = '
<?php echo "test & test"; ?>
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testVeryLongFilePath(): void + { + $longPath = str_repeat('a', 300); + $text = '
echo "test";
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + // File path should be truncated to 255 characters + $this->assertStringContainsString('...', $output); + // Check that the displayed path is truncated + preg_match('/(.*?)<\/span>/', $output, $matches); + if (isset($matches[1])) { + $this->assertLessThanOrEqual(255, strlen($matches[1])); + } + } + + public function testMultiplePreTags(): void + { + $text = '
echo "1";
echo "2"
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + $this->assertGreaterThan(1, substr_count($output, 'code-block-wrapper')); + } + + public function testInvalidCustomThemes(): void + { + $this->expectException(InvalidThemeException::class); + $this->expectExceptionMessage('All custom themes must be instances of'); + + new Highlighter('test', 'default', ['not a theme']); + } + + public function testInvalidThemeFallback(): void + { + $text = '
echo "test";
'; + $highlighter = new Highlighter($text, DefaultTheme::TITLE); + + // Should fall back to default theme without exception + $output = $highlighter->parse(); + $this->assertStringContainsString('code-block-wrapper', $output); + } + + // Language Tests + + public function testBashLanguage(): void + { + $text = '
echo "Hello World"
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + $this->assertStringContainsString('echo', $output); + } + + public function testXmlLanguage(): void + { + $text = '
test
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + $this->assertStringContainsString('root', $output); + } + + public function testHtmlLanguage(): void + { + $text = '
Content
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + $this->assertStringContainsString('div', $output); + } + + public function testJavaScriptLanguage(): void + { + $text = '
function test() { return true; }
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testJSLanguageAlias(): void + { + $text = '
const x = 1;
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testGoLanguage(): void + { + $text = '
package main
+func main() {
+    println("Hello")
+}
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testUnknownLanguageUsesPHPHighlighter(): void + { + $text = '
print("Hello")
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + // Should use PHP highlighter (base logic) without throwing exception + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testLanguageCaseInsensitive(): void + { + $text = '
echo "test";
'; + $highlighter = new Highlighter($text); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + // Empty Lines Tests + + public function testEmptyLinesInMiddleArePreserved(): void + { + $text = '
echo "first";
+
+echo "second";
'; + $highlighter = new Highlighter($text); + $highlighter->showLineNumbers(true); + $output = $highlighter->parse(); + + // Count line numbers - should have 3 lines (first line, empty line, second line) + preg_match_all('/]*>(\d+)<\/span>/', $output, $matches); + if (!empty($matches[1])) { + $lineNumbers = array_map('intval', $matches[1]); + // Should start from line 1 + $this->assertEquals(1, min($lineNumbers)); + // Should have at least 3 lines + $this->assertGreaterThanOrEqual(3, count($lineNumbers)); + } + + // Should contain both echo statements (may be HTML-escaped) + $this->assertStringContainsString('echo', $output); + $this->assertStringContainsString('first', $output); + $this->assertStringContainsString('second', $output); + } + + public function testEmptyLinesAtStartAndEndAreRemoved(): void + { + $text = '
+
+echo "test";
+
+
'; + $highlighter = new Highlighter($text); + $highlighter->showLineNumbers(true); + $output = $highlighter->parse(); + + // Count line numbers + preg_match_all('/]*>(\d+)<\/span>/', $output, $matches); + if (!empty($matches[1])) { + $lineNumbers = array_map('intval', $matches[1]); + // Should start from line 1 (empty lines at start removed) + $this->assertEquals(1, min($lineNumbers)); + // Should have exactly 1 line (empty lines at start and end removed) + // The echo line itself + $this->assertLessThanOrEqual(3, count($lineNumbers), 'Should have no more than 3 lines (including empty line in middle)'); + } + + // Should contain the echo statement (may be HTML-escaped) + $this->assertStringContainsString('echo', $output); + $this->assertStringContainsString('test', $output); + } + + public function testEmptyLinesRemovedForPHP(): void + { + $text = '
+
+<?php
+echo "test";
+
+
'; + $highlighter = new Highlighter($text); + $highlighter->showLineNumbers(true); + $output = $highlighter->parse(); + + // Count line numbers + preg_match_all('/]*>(\d+)<\/span>/', $output, $matches); + if (!empty($matches[1])) { + $lineNumbers = array_map('intval', $matches[1]); + // Should start from line 1 (empty line at start removed) + $this->assertEquals(1, min($lineNumbers)); + } + + // Should contain the code (may be HTML-escaped) + $this->assertStringContainsString('echo', $output); + $this->assertStringContainsString('test', $output); + } + + public function testEmptyLinesRemovedForJavaScript(): void + { + $text = '
+
+function test() {
+    return true;
+}
+
+
'; + $highlighter = new Highlighter($text); + $highlighter->showLineNumbers(true); + $output = $highlighter->parse(); + + // Count line numbers + preg_match_all('/]*>(\d+)<\/span>/', $output, $matches); + if (!empty($matches[1])) { + $lineNumbers = array_map('intval', $matches[1]); + // Should start from line 1 (empty line at start removed) + $this->assertEquals(1, min($lineNumbers)); + // Should not have excessive lines (empty lines at end removed) + $this->assertLessThanOrEqual(5, count($lineNumbers)); + } + + // Should contain the code (may be HTML-escaped) + $this->assertStringContainsString('function', $output); + $this->assertStringContainsString('return', $output); + $this->assertStringContainsString('true', $output); + } + + public function testEmptyLinesRemovedForGo(): void + { + $text = '
+
+package main
+
+func main() {
+    println("Hello")
+}
+
+
'; + $highlighter = new Highlighter($text); + $highlighter->showLineNumbers(true); + $output = $highlighter->parse(); + + // Count line numbers + preg_match_all('/]*>(\d+)<\/span>/', $output, $matches); + if (!empty($matches[1])) { + $lineNumbers = array_map('intval', $matches[1]); + // Should start from line 1 (empty line at start removed) + $this->assertEquals(1, min($lineNumbers)); + // Should not have excessive lines (empty lines at end removed) + $this->assertLessThanOrEqual(7, count($lineNumbers)); + } + + // Should contain the code (may be HTML-escaped) + $this->assertStringContainsString('package', $output); + $this->assertStringContainsString('main', $output); + $this->assertStringContainsString('func', $output); + } + + public function testEmptyLinesInMiddleArePreservedForPHP(): void + { + $text = '
echo "first";
+
+echo "middle";
+
+echo "last";
'; + $highlighter = new Highlighter($text); + $highlighter->showLineNumbers(true); + $output = $highlighter->parse(); + + // Count line numbers - should preserve empty lines in middle + preg_match_all('/]*>(\d+)<\/span>/', $output, $matches); + if (!empty($matches[1])) { + $lineNumbers = array_map('intval', $matches[1]); + // Should start from line 1 + $this->assertEquals(1, min($lineNumbers)); + // Should have at least 5 lines (3 echo lines + 2 empty lines) + $this->assertGreaterThanOrEqual(5, count($lineNumbers)); + } + + // Should contain all echo statements (may be HTML-escaped) + $this->assertStringContainsString('echo', $output); + $this->assertStringContainsString('first', $output); + $this->assertStringContainsString('middle', $output); + $this->assertStringContainsString('last', $output); + } + + // Custom Theme Tests + + public function testCustomTheme(): void + { + $customTheme = $this->createCustomTheme('custom-test'); + $text = '
echo "test";
'; + $highlighter = new Highlighter($text, 'custom-test', [$customTheme]); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testCustomThemeOverrideInBlock(): void + { + $customTheme = $this->createCustomTheme('custom-block'); + $text = '
echo "test";
'; + $highlighter = new Highlighter($text, DefaultTheme::TITLE, [$customTheme]); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testMultipleCustomThemes(): void + { + $theme1 = $this->createCustomTheme('theme1'); + $theme2 = $this->createCustomTheme('theme2'); + $text = '
echo "test";
'; + $highlighter = new Highlighter($text, 'theme1', [$theme1, $theme2]); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + } + + public function testCustomThemeWithDifferentLanguages(): void + { + $customTheme = $this->createCustomTheme('multi-lang'); + $text = '
echo "php";
echo "bash"
'; + $highlighter = new Highlighter($text, 'multi-lang', [$customTheme]); + $output = $highlighter->parse(); + + $this->assertStringContainsString('code-block-wrapper', $output); + $this->assertGreaterThan(1, substr_count($output, 'code-block-wrapper')); + } + + // Helper Methods + + private function createCustomTheme(string $title): Theme + { + $defaultColorSchemaDto = new DefaultColorSchemaDto( + '#000000', + '#ffffff', + '#888888', + '#ff0000', + '#00ff00', + '#0000ff', + '#ffff00', + ); + + $PHPColorSchemaDto = new PHPColorSchemaDto( + '#0000BB', + '#FF8000', + '#fbc201', + '#007700', + '#DD0000', + ); + + $XMLColorSchemaDto = new XMLColorSchemaDto( + '#008000', + '#7D9029', + '#BA2121', + '#BC7A00', + ); + + return new Theme($title, $defaultColorSchemaDto, $PHPColorSchemaDto, $XMLColorSchemaDto); + } + private function getText(): string { return ' diff --git a/tests/LanguageNormalizerTest.php b/tests/LanguageNormalizerTest.php new file mode 100644 index 0000000..2f8c30d --- /dev/null +++ b/tests/LanguageNormalizerTest.php @@ -0,0 +1,46 @@ +assertEquals('php', LanguageNormalizer::normalize('PHP')); + $this->assertEquals('javascript', LanguageNormalizer::normalize('JAVASCRIPT')); + $this->assertEquals('bash', LanguageNormalizer::normalize('BASH')); + } + + public function testNormalizeWithWhitespace(): void + { + $this->assertEquals('php', LanguageNormalizer::normalize(' php ')); + $this->assertEquals('javascript', LanguageNormalizer::normalize(' javascript ')); + } + + public function testNormalizeAliases(): void + { + $this->assertEquals('javascript', LanguageNormalizer::normalize('js')); + $this->assertEquals('html', LanguageNormalizer::normalize('htm')); + } + + public function testNormalizeUnknownLanguage(): void + { + $this->assertEquals('python', LanguageNormalizer::normalize('python')); + $this->assertEquals('ruby', LanguageNormalizer::normalize('ruby')); + } + + public function testNormalizeEmptyString(): void + { + $this->assertEquals('', LanguageNormalizer::normalize('')); + $this->assertEquals('', LanguageNormalizer::normalize(' ')); + } + + public function testNormalizeCaseInsensitiveAliases(): void + { + $this->assertEquals('javascript', LanguageNormalizer::normalize('JS')); + $this->assertEquals('html', LanguageNormalizer::normalize('HTM')); + } +}