├── .github └── workflows │ ├── php.yml │ └── validator1.yml ├── .gitignore ├── README.md ├── composer.json ├── src ├── Libs │ └── Markdown │ │ └── ParseMarkdown.php └── Markdown.php └── tests ├── CompilationTest.php ├── MarkupTest.php ├── files ├── footer.md ├── heading.md ├── hello-2.md └── hello.md └── pages ├── backup └── homepage.html ├── compiledmarkdown.html ├── hello-2.html ├── hello-3.html └── homepage.html /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Update Composer 21 | run: composer update 22 | 23 | - name: Validate composer.json and composer.lock 24 | run: composer validate --strict 25 | 26 | - name: Cache Composer packages 27 | id: composer-cache 28 | uses: actions/cache@v3 29 | with: 30 | path: vendor 31 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 32 | restore-keys: | 33 | ${{ runner.os }}-php- 34 | 35 | - name: Install dependencies 36 | run: composer install --prefer-dist --no-progress 37 | 38 | - name: Dump Autoload 39 | run: composer dump-autoload -o 40 | 41 | - name: Run Test Suite 42 | run: php ./vendor/bin/phpunit tests 43 | 44 | 45 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 46 | # Docs: https://getcomposer.org/doc/articles/scripts.md 47 | 48 | # - name: Run test suite 49 | # run: composer run-script test 50 | -------------------------------------------------------------------------------- /.github/workflows/validator1.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ "v0.2.4" ] 6 | pull_request: 7 | branches: [ "v0.2.4" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate --strict 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | - name: Dump Autoload 36 | run: composer dump-autoload -o 37 | 38 | - name: Run Test Suite 39 | run: php ./vendor/bin/phpunit tests 40 | 41 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 42 | # Docs: https://getcomposer.org/doc/articles/scripts.md 43 | 44 | # - name: Run test suite 45 | # run: composer run-script test 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Fastvolt 4 | 5 |

6 | 7 | 8 |

Markdown Parser for PHP

9 | 10 |

11 | A fast, simple, and straightforward Markdown to HTML converter for PHP. 12 |

13 | 14 |

15 | 16 | PHP Composer 17 | 18 | 19 | License: MIT 20 | 21 | 22 | GitHub Issues 23 | 24 | 25 | Repo 26 | 27 | Maintained: Yes 28 |

29 | 30 | 31 | ## 🚀 Installation 32 | 33 | ```sh 34 | composer require fastvolt/markdown 35 | ``` 36 | 37 |
38 | 39 | ## 📦 Basic Usage 40 | 41 | ```php 42 | use FastVolt\Helper\Markdown; 43 | 44 | $text = "## Hello, World"; 45 | 46 | // initialize markdown object 47 | $markdown = new Markdown(); // or Markdown::new() 48 | 49 | // set markdown content 50 | $markdown->setContent($text); 51 | 52 | // compile as raw HTML 53 | echo $markdown->toHtml(); 54 | ``` 55 | 56 | #### Output: 57 | 58 | ```html 59 |

Hello, World

60 | ``` 61 | 62 |
63 | 64 | 65 | ## 📄 Convert Markdown File to Raw HTML 66 | 67 | > ***sample.md:*** 68 | 69 | ```md 70 | #### Heading 4 71 | ### Heading 3 72 | ## Heading 2 73 | # Heading 1 74 | 75 | - List 1 76 | - List 2 77 | 78 | > THIS IS A BLOCKQUOTE 79 | 80 | [A LINK](https://github.com/fastvolt) 81 | ``` 82 | 83 | 84 | > ***index.php:*** 85 | 86 | ```php 87 | $markdown = Markdown::new(); 88 | 89 | // set markdown file to parse 90 | $markdown->setFile('./sample.md'); 91 | 92 | // compile as raw HTML 93 | echo $markdown->toHtml(); 94 | ``` 95 | 96 | > ***Output:*** 97 | 98 | ```html 99 |

Heading 4

100 |

Heading 3

101 |

Heading 2

102 |

Heading 1

103 | 107 |

THIS IS A BLOCKQUOTE

108 | A LINK 109 | ``` 110 | 111 |
112 | 113 | ## 📝 Convert Markdown File to An HTML File 114 | 115 | > ***blogPost.md:*** 116 | 117 | ```md 118 | Here is a Markdown File Waiting To Be Compiled To an HTML File 119 | ``` 120 | 121 | > ***index.php:*** 122 | 123 | ```php 124 | 125 | $markdown = Markdown::new() 126 | // set markdown file 127 | ->setFile(__DIR__ . '/blogPost.md') 128 | // set compilation directory 129 | ->setCompileDir(__DIR__ . '/pages/') 130 | // compile as an html file 'newHTMLFile.html' 131 | ->toHtmlFile(filename: 'newHTMLFile'); 132 | 133 | if ($markdown) { 134 | echo "Compiled to ./pages/newHTMLFile.html"; 135 | } 136 | 137 | ``` 138 | 139 |
140 | 141 | ## 🔒 Sanitizing HTML Output (XSS Protection) 142 | 143 | You can sanitize input HTML and prevent cross-site scripting (XSS) attack using the `sanitize` flag: 144 | 145 | ```php 146 | $markdown = Markdown::new( 147 | sanitize: true 148 | ); 149 | 150 | $markdown->setContent('

Hello World

'); 151 | 152 | echo $markdown->toHtml(); 153 | ``` 154 | 155 | > ***Output:*** 156 | 157 | ```html 158 |

<h1>Hello World</h1>

159 | ``` 160 | 161 |
162 | 163 | ## ⚙️ Advanced Use Case 164 | 165 | ### Inline Markdown 166 | ```php 167 | $markdown = Markdown::new(); 168 | 169 | $markdown->setInlineContent('_My name is **vincent**, the co-author of this blog_'); 170 | 171 | echo $markdown->toHtml(); 172 | ``` 173 | 174 | > ***Output:*** 175 | 176 | ```html 177 | My name is vincent, the co-author of this blog 178 | ``` 179 | 180 | > ***NOTE:*** Some markdown symbols are not supported with this method 181 | 182 |
183 | 184 | ### Example #1 185 | Combine multiple markdown files, contents and compile them in multiple directories: 186 | 187 | > ***Header.md*** 188 | ```md 189 | # Blog Title 190 | ### Here is the Blog Sub-title 191 | ``` 192 | 193 | > ***Footer.md*** 194 | ```md 195 | ### Thanks for Visiting My BlogPage 196 | ``` 197 | 198 | > ***index.php*** 199 | 200 | ```php 201 | $markdown = Markdown::new(sanitize: true) 202 | // include header file's markdown contents 203 | ->setFile('./Header.md') 204 | // body contents 205 | ->setInlineContent('_My name is **vincent**, the co-author of this blog_') 206 | ->setContent('Kindly follow me on my GitHub page via: [@vincent](https://github.com/oladoyinbov).') 207 | ->setContent('Here are the lists of my projects:') 208 | ->setContent(' 209 | - Dragon CMS 210 | - Fastvolt Framework. 211 | + Fastvolt Router 212 | + Markdown Parser. 213 | ') 214 | // include footer file's markdown contents 215 | ->setFile('./Footer.md') 216 | // set compilation directory 217 | ->setCompileDir('./pages/') 218 | // set another compilation directory to backup the result 219 | ->setCompileDir('./backup/pages/') 220 | // compile and store as 'homepage.html' 221 | ->toHtmlFile(file_name: 'homepage'); 222 | 223 | if ($markdown) { 224 | echo "Compile Successful"; 225 | } 226 | ``` 227 | 228 | > ***Output:*** `pages/homepage.html`, `backup/pages/homepage.html` 229 | 230 | ```html 231 |

Blog Title

232 |

Here is the Blog Sub-title

233 | My name is vincent, the co-author of this blog 234 |

Kindly follow me on my github page via: @vincent.

235 |

Here are the lists of my projects:

236 | 245 |

Thanks for Visiting My BlogPage

246 | ``` 247 | 248 |
249 | 250 | ## Supported Formatting Symbols 251 | 252 | | Markdown Syntax | Description | Example Syntax | Rendered Output | 253 | |-----------------------------|-----------------------------|-------------------------------------------|----------------------------------------| 254 | | `#` to `######` | Headings (H1–H6) | `## Heading 2` |

Heading 2

| 255 | | `**text**` or `__text__` | Bold | `**bold**` | bold | 256 | | `*text*` or `_text_` | Italic | `*italic*` | italic | 257 | | `~~text~~` | Strikethrough | `~~strike~~` | strike | 258 | | `` `code` `` | Inline code | `` `echo` `` | echo | 259 | | ```
code block
```
| Code block | ```` ```php\n echo "Hi"; \n``` ```` | `
...
` | 260 | | `-`, `+`, or `*` | Unordered list | `- Item 1`
`* Item 2` | `` | 261 | | `1.` `2.` | Ordered list | `1. Item`
`2. Item` | `
  1. Item
` | 262 | | `[text](url)` | Hyperlink | `[GitHub](https://github.com)` | GitHub | 263 | | `> blockquote` | Blockquote | `> This is a quote` |
This is a quote
| 264 | | `---`, `***`, `___` | Horizontal Rule | `---` | `
` | 265 | | `![alt](image.jpg)` | Image | `![Logo](logo.png)` | `Logo` | 266 | | `\` | Escape special character | `\*not italic\*` | *not italic* (as text) | 267 | 268 |
269 | 270 | ## ✅ Requirements 271 | 272 | PHP 8.1 or newer. 273 | 274 |
275 | 276 | ## ℹ️ Notes 277 | 278 | > This library is an extended and simplified version of the excellent [Parsedown](https://github.com/erusev/parsedown/) by Erusev. 279 | 280 |
281 | 282 | ## 📄 License 283 | 284 | This project is open-source and licensed under the MIT License by @fastvolt. 285 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastvolt/markdown", 3 | "description": "A Fast, Simple and Straight-forward Markdown to HTML Converter for PHP.", 4 | "keywords": [ 5 | "markdown", 6 | "markdown-to-html", 7 | "markdown-parser", 8 | "markdown-library" 9 | ], 10 | "type": "library", 11 | "license": "MIT", 12 | "autoload": { 13 | "psr-4": { 14 | "FastVolt\\Helper\\": "src/" 15 | } 16 | }, 17 | "authors": [ 18 | { 19 | "name": "Oladoyinbo Vincent", 20 | "email": "oladoyinboadverts@gmail.com" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.1", 25 | "ext-dom": "*", 26 | "ext-json": "*", 27 | "ext-libxml": "*", 28 | "ext-mbstring": "*", 29 | "ext-tokenizer": "*", 30 | "ext-xml": "*", 31 | "ext-xmlwriter": "*" 32 | }, 33 | "require-dev": { 34 | "phpunit/phpunit": "^10.4", 35 | "friendsofphp/php-cs-fixer": "^3.38" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Libs/Markdown/ParseMarkdown.php: -------------------------------------------------------------------------------- 1 | `~\\'; 20 | protected $urlsLinked = true; 21 | 22 | protected array $specialCharacters = [ 23 | '\\', 24 | '`', 25 | '*', 26 | '_', 27 | '{', 28 | '}', 29 | '[', 30 | ']', 31 | '(', 32 | ')', 33 | '>', 34 | '#', 35 | '+', 36 | '-', 37 | '.', 38 | '!', 39 | '|', 40 | ]; 41 | 42 | protected array $StrongRegex = [ 43 | '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s', 44 | '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us', 45 | ]; 46 | 47 | protected array $EmRegex = [ 48 | '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', 49 | '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', 50 | ]; 51 | 52 | protected string $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?'; 53 | 54 | protected array $voidmarkdownElements = [ 55 | 'area', 56 | 'base', 57 | 'br', 58 | 'col', 59 | 'command', 60 | 'embed', 61 | 'hr', 62 | 'img', 63 | 'input', 64 | 'link', 65 | 'meta', 66 | 'param', 67 | 'source', 68 | ]; 69 | 70 | protected array $textLevelmarkdownElements = [ 71 | 'a', 72 | 'br', 73 | 'bdo', 74 | 'abbr', 75 | 'blink', 76 | 'nextid', 77 | 'acronym', 78 | 'basefont', 79 | 'b', 80 | 'em', 81 | 'big', 82 | 'cite', 83 | 'small', 84 | 'spacer', 85 | 'listing', 86 | 'i', 87 | 'rp', 88 | 'del', 89 | 'code', 90 | 'strike', 91 | 'marquee', 92 | 'q', 93 | 'rt', 94 | 'ins', 95 | 'font', 96 | 'strong', 97 | 's', 98 | 'tt', 99 | 'kbd', 100 | 'mark', 101 | 'u', 102 | 'xm', 103 | 'sub', 104 | 'nobr', 105 | 'sup', 106 | 'ruby', 107 | 'var', 108 | 'span', 109 | 'wbr', 110 | 'time', 111 | ]; 112 | 113 | protected array $safeLinksWhitelist = [ 114 | 'http://', 115 | 'https://', 116 | 'ftp://', 117 | 'ftps://', 118 | 'mailto:', 119 | 'data:image/png;base64,', 120 | 'data:image/gif;base64,', 121 | 'data:image/jpeg;base64,', 122 | 'irc:', 123 | 'ircs:', 124 | 'git:', 125 | 'ssh:', 126 | 'news:', 127 | 'steam:', 128 | ]; 129 | 130 | protected array $markdownBlockTypes = [ 131 | '#' => ['Header'], 132 | '*' => ['Rule', 'List'], 133 | '+' => ['List'], 134 | '-' => ['SetextHeader', 'Table', 'Rule', 'List'], 135 | '0' => ['List'], 136 | '1' => ['List'], 137 | '2' => ['List'], 138 | '3' => ['List'], 139 | '4' => ['List'], 140 | '5' => ['List'], 141 | '6' => ['List'], 142 | '7' => ['List'], 143 | '8' => ['List'], 144 | '9' => ['List'], 145 | ':' => ['Table'], 146 | '<' => ['Comment', 'Markup'], 147 | '=' => ['SetextHeader'], 148 | '>' => ['Quote'], 149 | '[' => ['Reference'], 150 | '_' => ['Rule'], 151 | '`' => ['FencedCode'], 152 | '|' => ['Table'], 153 | '~' => ['FencedCode'], 154 | ]; 155 | 156 | protected array $unmarkedmarkdownBlockTypes = [ 157 | 'Code', 158 | ]; 159 | 160 | protected array $InlineTypes = [ 161 | '"' => ['SpecialCharacter'], 162 | '!' => ['Image'], 163 | '&' => ['SpecialCharacter'], 164 | '*' => ['Emphasis'], 165 | ':' => ['Url'], 166 | '<' => ['UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'], 167 | '>' => ['SpecialCharacter'], 168 | '[' => ['Link'], 169 | '_' => ['Emphasis'], 170 | '`' => ['Code'], 171 | '~' => ['Strikethrough'], 172 | '\\' => ['EscapeSequence'], 173 | ]; 174 | 175 | public function __construct( 176 | protected bool $sanitize = true 177 | ) { 178 | $this->setBreaksEnabled = true; 179 | $this->DefinitionData = null; 180 | $this->safeMode = $sanitize; 181 | 182 | if (true === $sanitize) { 183 | $this->strictMode = true; 184 | $this->setUrlsLinked = false; 185 | $this->setMarkupEscaped = false; 186 | } 187 | } 188 | 189 | /** 190 | * Set Markup Contents 191 | * 192 | * @param string $text 193 | * 194 | * @return string 195 | */ 196 | public function markdown_text(string $text): string 197 | { 198 | # make sure no definitions are set 199 | $this->DefinitionData = []; 200 | 201 | # standardize line breaks 202 | $text = str_replace(["\r\n", "\r"], "\n", $text); 203 | 204 | # remove surrounding line breaks 205 | $text = trim($text, "\n"); 206 | 207 | # split text into lines 208 | $lines = explode("\n", $text); 209 | 210 | # iterate through lines to identify blocks 211 | $markup = $this->lines($lines); 212 | 213 | # trim line breaks 214 | $markup = trim($markup, "\n"); 215 | 216 | return $markup; 217 | } 218 | 219 | # Setters 220 | private function setBreaksEnabled(bool $breaksEnabled): self 221 | { 222 | $this->breaksEnabled = $breaksEnabled; 223 | 224 | return $this; 225 | } 226 | 227 | private function setMarkupEscaped(bool $markupEscaped): self 228 | { 229 | $this->markupEscaped = $markupEscaped; 230 | 231 | return $this; 232 | } 233 | 234 | private function setUrlsLinked(bool $urlsLinked): self 235 | { 236 | $this->urlsLinked = $urlsLinked; 237 | 238 | return $this; 239 | } 240 | 241 | private function setSafeMode(bool $safeMode) 242 | { 243 | $this->safeMode = (bool) $safeMode; 244 | 245 | return $this; 246 | } 247 | 248 | protected function lines(array $lines): ?string 249 | { 250 | $CurrentmarkdownBlock = null; 251 | 252 | foreach ($lines as $line) { 253 | 254 | if (chop($line) === '') { 255 | 256 | if (isset($CurrentmarkdownBlock)) { 257 | $CurrentmarkdownBlock['interrupted'] = true; 258 | } 259 | 260 | continue; 261 | } 262 | 263 | if (strpos($line, "\t") !== false) { 264 | 265 | $parts = explode("\t", $line); 266 | 267 | $line = $parts[0]; 268 | 269 | unset($parts[0]); 270 | 271 | foreach ($parts as $part) { 272 | $shortage = 4 - mb_strlen($line, 'utf-8') % 4; 273 | 274 | $line .= str_repeat(' ', $shortage); 275 | $line .= $part; 276 | } 277 | } 278 | 279 | $indent = 0; 280 | 281 | while (isset($line[$indent]) and $line[$indent] === ' ') { 282 | $indent++; 283 | } 284 | 285 | $text = $indent > 0 ? substr($line, $indent) : $line; 286 | 287 | $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); 288 | 289 | if (isset($CurrentmarkdownBlock['continuable'])) { 290 | 291 | $markdownBlock = $this->{'block' . $CurrentmarkdownBlock['type'] . 'Continue'}($Line, $CurrentmarkdownBlock); 292 | 293 | if (isset($markdownBlock)) { 294 | 295 | $CurrentmarkdownBlock = $markdownBlock; 296 | 297 | continue; 298 | 299 | } else { 300 | 301 | if ($this->ismarkdownBlockCompletable($CurrentmarkdownBlock['type'])) { 302 | $CurrentmarkdownBlock = $this->{'block' . $CurrentmarkdownBlock['type'] . 'Complete'}($CurrentmarkdownBlock); 303 | } 304 | } 305 | } 306 | 307 | $marker = $text[0]; 308 | 309 | $blockTypes = $this->unmarkedmarkdownBlockTypes; 310 | 311 | if (isset($this->markdownBlockTypes[$marker])) { 312 | foreach ($this->markdownBlockTypes[$marker] as $blockType) { 313 | $blockTypes[] = $blockType; 314 | } 315 | } 316 | 317 | foreach ($blockTypes as $blockType) { 318 | $markdownBlock = $this->{'block' . $blockType}($Line, $CurrentmarkdownBlock); 319 | 320 | if (isset($markdownBlock)) { 321 | $markdownBlock['type'] = $blockType; 322 | 323 | if (!isset($markdownBlock['identified'])) { 324 | $markdownBlocks[] = $CurrentmarkdownBlock; 325 | 326 | $markdownBlock['identified'] = true; 327 | } 328 | 329 | if ($this->ismarkdownBlockContinuable($blockType)) { 330 | $markdownBlock['continuable'] = true; 331 | } 332 | 333 | $CurrentmarkdownBlock = $markdownBlock; 334 | 335 | continue 2; 336 | } 337 | } 338 | 339 | if (isset($CurrentmarkdownBlock) and !isset($CurrentmarkdownBlock['type']) and !isset($CurrentmarkdownBlock['interrupted'])) { 340 | 341 | $CurrentmarkdownBlock['element']['text'] .= "\n" . $text; 342 | 343 | } else { 344 | $markdownBlocks[] = $CurrentmarkdownBlock; 345 | 346 | $CurrentmarkdownBlock = $this->paragraph($Line); 347 | 348 | $CurrentmarkdownBlock['identified'] = true; 349 | } 350 | } 351 | 352 | if (isset($CurrentmarkdownBlock['continuable']) and $this->ismarkdownBlockCompletable($CurrentmarkdownBlock['type'])) { 353 | $CurrentmarkdownBlock = $this->{'block' . $CurrentmarkdownBlock['type'] . 'Complete'}($CurrentmarkdownBlock); 354 | } 355 | 356 | $markdownBlocks[] = $CurrentmarkdownBlock; 357 | 358 | unset($markdownBlocks[0]); 359 | 360 | $markup = ''; 361 | 362 | foreach ($markdownBlocks as $markdownBlock) { 363 | if (isset($markdownBlock['hidden'])) { 364 | continue; 365 | } 366 | 367 | $markup .= "\n"; 368 | $markup .= isset($markdownBlock['markup']) ? $markdownBlock['markup'] : $this->element($markdownBlock['element']); 369 | } 370 | 371 | $markup .= "\n"; 372 | 373 | return $markup; 374 | } 375 | 376 | protected function ismarkdownBlockContinuable(string $Type): bool 377 | { 378 | return method_exists($this, 'block' . $Type . 'Continue'); 379 | } 380 | 381 | protected function ismarkdownBlockCompletable(string $Type): bool 382 | { 383 | return method_exists($this, 'block' . $Type . 'Complete'); 384 | } 385 | 386 | protected function blockCode(array $Line, ?array $markdownBlock = null): ?array 387 | { 388 | if (isset($markdownBlock) and !isset($markdownBlock['type']) and !isset($markdownBlock['interrupted'])) { 389 | return null; 390 | } 391 | 392 | if ($Line['indent'] >= 4) { 393 | 394 | $text = substr($Line['body'], 4); 395 | 396 | $markdownBlock = [ 397 | 'element' => [ 398 | 'name' => 'pre', 399 | 'handler' => 'element', 400 | 'text' => [ 401 | 'name' => 'code', 402 | 'text' => $text, 403 | ], 404 | ], 405 | ]; 406 | 407 | return $markdownBlock; 408 | } 409 | 410 | return null; 411 | } 412 | 413 | protected function blockCodeContinue(array $Line, array $markdownBlock) 414 | { 415 | if ($Line['indent'] >= 4) { 416 | 417 | if (isset($markdownBlock['interrupted'])) { 418 | $markdownBlock['element']['text']['text'] .= "\n"; 419 | 420 | unset($markdownBlock['interrupted']); 421 | } 422 | 423 | $markdownBlock['element']['text']['text'] .= "\n"; 424 | 425 | $text = substr($Line['body'], 4); 426 | 427 | $markdownBlock['element']['text']['text'] .= $text; 428 | 429 | return $markdownBlock; 430 | } 431 | } 432 | 433 | protected function blockCodeComplete(array $markdownBlock) 434 | { 435 | $text = $markdownBlock['element']['text']['text']; 436 | 437 | $markdownBlock['element']['text']['text'] = $text; 438 | 439 | return $markdownBlock; 440 | } 441 | 442 | /** 443 | * markdownBlock Comment 444 | * 445 | * @param array $Line 446 | * 447 | * @return mixed 448 | */ 449 | protected function blockComment(array $Line) 450 | { 451 | if ($this->markupEscaped or $this->safeMode) { 452 | return null; 453 | } 454 | 455 | if ( 456 | isset($Line['text'][3]) && 457 | $Line['text'][3] === '-' && 458 | $Line['text'][2] === '-' && 459 | $Line['text'][1] === '!' 460 | ) { 461 | 462 | $markdownBlock = [ 463 | 'markup' => $Line['body'], 464 | ]; 465 | 466 | if (preg_match('/-->$/', $Line['text'])) { 467 | $markdownBlock['closed'] = true; 468 | } 469 | 470 | return $markdownBlock; 471 | } 472 | } 473 | 474 | protected function blockCommentContinue(array $Line, array $markdownBlock): ?array 475 | { 476 | if (isset($markdownBlock['closed'])) { 477 | return null; 478 | } 479 | 480 | $markdownBlock['markup'] .= "\n" . $Line['body']; 481 | 482 | if (preg_match('/-->$/', $Line['text'])) { 483 | $markdownBlock['closed'] = true; 484 | } 485 | 486 | return $markdownBlock; 487 | } 488 | 489 | 490 | /** 491 | * Fenced Codes 492 | * 493 | * @param array $Line 494 | * 495 | * @return mixed 496 | */ 497 | protected function blockFencedCode(array $Line): ?array 498 | { 499 | if (preg_match('/^[' . $Line['text'][0] . ']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches)) { 500 | 501 | $markdownElement = [ 502 | 'name' => 'code', 503 | 'text' => '', 504 | ]; 505 | 506 | if (isset($matches[1])) { 507 | /** 508 | * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes 509 | * Every HTML element may have a class attribute specified. 510 | * The attribute, if specified, must have a value that is a set 511 | * of space-separated tokens representing the various classes 512 | * that the element belongs to. 513 | * [...] 514 | * The space characters, for the purposes of this specification, 515 | * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), 516 | * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and 517 | * U+000D CARRIAGE RETURN (CR). 518 | */ 519 | $language = substr($matches[1], 0, strcspn($matches[1], " \t\n\f\r")); 520 | 521 | $class = 'language-' . $language; 522 | 523 | $markdownElement['attributes'] = [ 524 | 'class' => $class, 525 | ]; 526 | } 527 | 528 | $markdownBlock = [ 529 | 'char' => $Line['text'][0], 530 | 'element' => [ 531 | 'name' => 'pre', 532 | 'handler' => 'element', 533 | 'text' => $markdownElement, 534 | ], 535 | ]; 536 | 537 | return $markdownBlock; 538 | } 539 | 540 | return null; 541 | } 542 | 543 | protected function blockFencedCodeContinue(array $Line, array $markdownBlock): ?array 544 | { 545 | if (isset($markdownBlock['complete'])) { 546 | return null; 547 | } 548 | 549 | if (isset($markdownBlock['interrupted'])) { 550 | $markdownBlock['element']['text']['text'] .= "\n"; 551 | 552 | unset($markdownBlock['interrupted']); 553 | } 554 | 555 | if (preg_match('/^' . $markdownBlock['char'] . '{3,}[ ]*$/', $Line['text'])) { 556 | $markdownBlock['element']['text']['text'] = substr($markdownBlock['element']['text']['text'], 1); 557 | 558 | $markdownBlock['complete'] = true; 559 | 560 | return $markdownBlock; 561 | } 562 | 563 | $markdownBlock['element']['text']['text'] .= "\n" . $Line['body']; 564 | 565 | return $markdownBlock; 566 | } 567 | 568 | protected function blockFencedCodeComplete(array $markdownBlock): ?array 569 | { 570 | $text = $markdownBlock['element']['text']['text']; 571 | 572 | $markdownBlock['element']['text']['text'] = $text; 573 | 574 | return $markdownBlock; 575 | } 576 | 577 | /** 578 | * markdownBlock Header 579 | * 580 | * @param array $Line 581 | * 582 | * @return ?array 583 | */ 584 | protected function blockHeader($Line): ?array 585 | { 586 | if (isset($Line['text'][1])) { 587 | $level = 1; 588 | 589 | while (isset($Line['text'][$level]) && $Line['text'][$level] === '#') { 590 | $level++; 591 | } 592 | 593 | if ($level > 6) { 594 | return null; 595 | } 596 | 597 | $text = trim($Line['text'], '# '); 598 | 599 | $markdownBlock = array( 600 | 'element' => array( 601 | 'name' => 'h' . min(6, $level), 602 | 'text' => $text, 603 | 'handler' => 'line', 604 | ), 605 | ); 606 | 607 | return $markdownBlock; 608 | } 609 | 610 | return null; 611 | } 612 | 613 | protected function blockList(array $Line) 614 | { 615 | [$name, $pattern] = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]'); 616 | 617 | if (preg_match('/^(' . $pattern . '[ ]+)(.*)/', $Line['text'], $matches)) { 618 | $markdownBlock = array( 619 | 'indent' => $Line['indent'], 620 | 'pattern' => $pattern, 621 | 'element' => array( 622 | 'name' => $name, 623 | 'handler' => 'elements', 624 | ), 625 | ); 626 | 627 | if ($name === 'ol') { 628 | $listStart = stristr($matches[0], '.', true); 629 | 630 | if ($listStart !== '1') { 631 | $markdownBlock['element']['attributes'] = array('start' => $listStart); 632 | } 633 | } 634 | 635 | $markdownBlock['li'] = array( 636 | 'name' => 'li', 637 | 'handler' => 'li', 638 | 'text' => array( 639 | $matches[2], 640 | ), 641 | ); 642 | 643 | $markdownBlock['element']['text'][] = &$markdownBlock['li']; 644 | 645 | return $markdownBlock; 646 | } 647 | } 648 | 649 | protected function blockListContinue(array $Line, array $markdownBlock) 650 | { 651 | if ($markdownBlock['indent'] === $Line['indent'] and preg_match('/^' . $markdownBlock['pattern'] . '(?:[ ]+(.*)|$)/', $Line['text'], $matches)) { 652 | if (isset($markdownBlock['interrupted'])) { 653 | $markdownBlock['li']['text'][] = ''; 654 | 655 | $markdownBlock['loose'] = true; 656 | 657 | unset($markdownBlock['interrupted']); 658 | } 659 | 660 | unset($markdownBlock['li']); 661 | 662 | $text = isset($matches[1]) ? $matches[1] : ''; 663 | 664 | $markdownBlock['li'] = array( 665 | 'name' => 'li', 666 | 'handler' => 'li', 667 | 'text' => array( 668 | $text, 669 | ), 670 | ); 671 | 672 | $markdownBlock['element']['text'][] = &$markdownBlock['li']; 673 | 674 | return $markdownBlock; 675 | } 676 | 677 | if ($Line['text'][0] === '[' and $this->blockReference($Line)) { 678 | return $markdownBlock; 679 | } 680 | 681 | if (!isset($markdownBlock['interrupted'])) { 682 | $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); 683 | 684 | $markdownBlock['li']['text'][] = $text; 685 | 686 | return $markdownBlock; 687 | } 688 | 689 | if ($Line['indent'] > 0) { 690 | $markdownBlock['li']['text'][] = ''; 691 | 692 | $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']); 693 | 694 | $markdownBlock['li']['text'][] = $text; 695 | 696 | unset($markdownBlock['interrupted']); 697 | 698 | return $markdownBlock; 699 | } 700 | 701 | return null; 702 | } 703 | 704 | protected function blockListComplete(array $markdownBlock) 705 | { 706 | if (isset($markdownBlock['loose'])) { 707 | foreach ($markdownBlock['element']['text'] as &$li) { 708 | if (end($li['text']) !== '') { 709 | $li['text'][] = ''; 710 | } 711 | } 712 | } 713 | 714 | return $markdownBlock; 715 | } 716 | 717 | protected function blockQuote(array $Line): ?array 718 | { 719 | if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) { 720 | 721 | $markdownBlock = array( 722 | 'element' => array( 723 | 'name' => 'blockquote', 724 | 'handler' => 'lines', 725 | 'text' => (array) $matches[1], 726 | ), 727 | ); 728 | 729 | return $markdownBlock; 730 | } 731 | 732 | return null; 733 | } 734 | 735 | protected function blockQuoteContinue(array $Line, array $markdownBlock): ?array 736 | { 737 | if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches)) { 738 | 739 | if (isset($markdownBlock['interrupted'])) { 740 | 741 | $markdownBlock['element']['text'][] = ''; 742 | 743 | unset($markdownBlock['interrupted']); 744 | } 745 | 746 | $markdownBlock['element']['text'][] = $matches[1]; 747 | 748 | return $markdownBlock; 749 | } 750 | 751 | if (!isset($markdownBlock['interrupted'])) { 752 | 753 | $markdownBlock['element']['text'][] = $Line['text']; 754 | 755 | return $markdownBlock; 756 | } 757 | 758 | return null; 759 | } 760 | 761 | protected function blockRule(array $Line): ?array 762 | { 763 | if (preg_match('/^([' . $Line['text'][0] . '])([ ]*\1){2,}[ ]*$/', $Line['text'])) { 764 | 765 | $markdownBlock = array( 766 | 'element' => array( 767 | 'name' => 'hr' 768 | ), 769 | ); 770 | 771 | return $markdownBlock; 772 | } 773 | 774 | return null; 775 | } 776 | 777 | protected function blockSetextHeader(array $Line, ?array $markdownBlock = []): ?array 778 | { 779 | if (!isset($markdownBlock) or isset($markdownBlock['type']) or isset($markdownBlock['interrupted'])) { 780 | return null; 781 | } 782 | 783 | if (chop($Line['text'], $Line['text'][0]) === '') { 784 | 785 | $markdownBlock['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; 786 | 787 | return $markdownBlock; 788 | } 789 | 790 | return null; 791 | } 792 | 793 | protected function blockMarkup(array $Line): ?array 794 | { 795 | if ($this->markupEscaped or $this->safeMode) { 796 | return null; 797 | } 798 | 799 | if (preg_match('/^<(\w[\w-]*)(?:[ ]*' . $this->regexHtmlAttribute . ')*[ ]*(\/)?>/', $Line['text'], $matches)) { 800 | 801 | $element = strtolower($matches[1]); 802 | 803 | if (in_array($element, $this->textLevelmarkdownElements)) { 804 | return null; 805 | } 806 | 807 | $markdownBlock = array( 808 | 'name' => $matches[1], 809 | 'depth' => 0, 810 | 'markup' => $Line['text'], 811 | ); 812 | 813 | $length = strlen($matches[0]); 814 | 815 | $remainder = substr($Line['text'], $length); 816 | 817 | if (trim($remainder) === '') { 818 | 819 | if (isset($matches[2]) or in_array($matches[1], $this->voidmarkdownElements)) { 820 | $markdownBlock['closed'] = true; 821 | 822 | $markdownBlock['void'] = true; 823 | } 824 | 825 | } else { 826 | if (isset($matches[2]) or in_array($matches[1], $this->voidmarkdownElements)) { 827 | return null; 828 | } 829 | 830 | if (preg_match('/<\/' . $matches[1] . '>[ ]*$/i', $remainder)) { 831 | $markdownBlock['closed'] = true; 832 | } 833 | } 834 | 835 | return $markdownBlock; 836 | } 837 | 838 | return null; 839 | } 840 | 841 | protected function blockMarkupContinue(array $Line, array $markdownBlock): ?array 842 | { 843 | if (isset($markdownBlock['closed'])) { 844 | return null; 845 | } 846 | 847 | if (preg_match('/^<' . $markdownBlock['name'] . '(?:[ ]*' . $this->regexHtmlAttribute . ')*[ ]*>/i', $Line['text'])) # open 848 | { 849 | $markdownBlock['depth']++; 850 | } 851 | 852 | if (preg_match('/(.*?)<\/' . $markdownBlock['name'] . '>[ ]*$/i', $Line['text'], $matches)) # close 853 | { 854 | if ($markdownBlock['depth'] > 0) { 855 | $markdownBlock['depth']--; 856 | } else { 857 | $markdownBlock['closed'] = true; 858 | } 859 | } 860 | 861 | if (isset($markdownBlock['interrupted'])) { 862 | $markdownBlock['markup'] .= "\n"; 863 | 864 | unset($markdownBlock['interrupted']); 865 | } 866 | 867 | $markdownBlock['markup'] .= "\n" . $Line['body']; 868 | 869 | return $markdownBlock; 870 | } 871 | 872 | protected function blockReference(array $Line): ?array 873 | { 874 | if (preg_match('/^\[(.+?)\]:[ ]*?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches)) { 875 | 876 | $id = strtolower($matches[1]); 877 | 878 | $Data = array( 879 | 'url' => $matches[2], 880 | 'title' => null, 881 | ); 882 | 883 | if (isset($matches[3])) { 884 | $Data['title'] = $matches[3]; 885 | } 886 | 887 | $this->DefinitionData['Reference'][$id] = $Data; 888 | 889 | $markdownBlock = array( 890 | 'hidden' => true, 891 | ); 892 | 893 | return $markdownBlock; 894 | } 895 | 896 | return null; 897 | } 898 | 899 | protected function blockTable($Line, ?array $markdownBlock = []) 900 | { 901 | if (!isset($markdownBlock) || isset($markdownBlock['type']) || isset($markdownBlock['interrupted'])) { 902 | return null; 903 | } 904 | 905 | if (strpos($markdownBlock['element']['text'], '|') !== false || chop($Line['text'], ' -:|') === '') { 906 | $alignments = []; 907 | 908 | $divider = $Line['text']; 909 | 910 | $divider = trim($divider); 911 | $divider = trim($divider, '|'); 912 | 913 | $dividerCells = explode('|', $divider); 914 | 915 | foreach ($dividerCells as $dividerCell) { 916 | $dividerCell = trim($dividerCell); 917 | 918 | if ($dividerCell === '') { 919 | continue; 920 | } 921 | 922 | $alignment = null; 923 | 924 | if ($dividerCell[0] === ':') { 925 | $alignment = 'left'; 926 | } 927 | 928 | if (substr($dividerCell, -1) === ':') { 929 | $alignment = $alignment === 'left' ? 'center' : 'right'; 930 | } 931 | 932 | $alignments[] = $alignment; 933 | } 934 | 935 | 936 | 937 | $HeadermarkdownElements = []; 938 | $header = $markdownBlock['element']['text']; 939 | $header = trim($header); 940 | $header = trim($header, '|'); 941 | $headerCells = explode('|', $header); 942 | 943 | foreach ($headerCells as $index => $headerCell) { 944 | $headerCell = trim($headerCell); 945 | $HeadermarkdownElement = [ 946 | 'name' => 'th', 947 | 'text' => $headerCell, 948 | 'handler' => 'line', 949 | ]; 950 | 951 | if (isset($alignments[$index])) { 952 | $alignment = $alignments[$index]; 953 | $HeadermarkdownElement['attributes'] = [ 954 | 'style' => 'text-align: ' . $alignment . ';', 955 | ]; 956 | } 957 | 958 | $HeadermarkdownElements[] = $HeadermarkdownElement; 959 | } 960 | 961 | $markdownBlock = [ 962 | 'alignments' => $alignments, 963 | 'identified' => true, 964 | 'element' => [ 965 | 'name' => 'table', 966 | 'handler' => 'elements', 967 | ], 968 | ]; 969 | 970 | $markdownBlock['element']['text'][] = [ 971 | 'name' => 'thead', 972 | 'handler' => 'elements', 973 | ]; 974 | 975 | $markdownBlock['element']['text'][] = array( 976 | 'name' => 'tbody', 977 | 'handler' => 'elements', 978 | 'text' => [], 979 | ); 980 | 981 | $markdownBlock['element']['text'][0]['text'][] = array( 982 | 'name' => 'tr', 983 | 'handler' => 'elements', 984 | 'text' => $HeadermarkdownElements, 985 | ); 986 | 987 | return $markdownBlock; 988 | } 989 | 990 | return null; 991 | } 992 | 993 | protected function blockTableContinue($Line, $markdownBlock) 994 | { 995 | if (isset($markdownBlock['interrupted'])) { 996 | return null; 997 | } 998 | 999 | if ($Line['text'][0] === '|' or strpos($Line['text'], '|')) { 1000 | $markdownElements = []; 1001 | 1002 | $row = $Line['text']; 1003 | 1004 | $row = trim($row); 1005 | $row = trim($row, '|'); 1006 | 1007 | preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches); 1008 | 1009 | foreach ($matches[0] as $index => $cell) { 1010 | $cell = trim($cell); 1011 | 1012 | $markdownElement = array( 1013 | 'name' => 'td', 1014 | 'handler' => 'line', 1015 | 'text' => $cell, 1016 | ); 1017 | 1018 | if (isset($markdownBlock['alignments'][$index])) { 1019 | $markdownElement['attributes'] = [ 1020 | 'style' => 'text-align: ' . $markdownBlock['alignments'][$index] . ';', 1021 | ]; 1022 | } 1023 | 1024 | $markdownElements[] = $markdownElement; 1025 | } 1026 | 1027 | $markdownElement = array( 1028 | 'name' => 'tr', 1029 | 'handler' => 'elements', 1030 | 'text' => $markdownElements, 1031 | ); 1032 | 1033 | $markdownBlock['element']['text'][1]['text'][] = $markdownElement; 1034 | 1035 | return $markdownBlock; 1036 | } 1037 | } 1038 | 1039 | protected function paragraph($Line) 1040 | { 1041 | $markdownBlock = array( 1042 | 'element' => array( 1043 | 'name' => 'p', 1044 | 'text' => $Line['text'], 1045 | 'handler' => 'line', 1046 | ), 1047 | ); 1048 | 1049 | return $markdownBlock; 1050 | } 1051 | 1052 | /** 1053 | * Inline markdownElement 1054 | * 1055 | * @param string $text 1056 | * @param array $nonNestables 1057 | * 1058 | */ 1059 | public function line(string $text, array $nonNestables = []) 1060 | { 1061 | $markup = ''; 1062 | 1063 | # $excerpt is based on the first occurrence of a marker 1064 | 1065 | while ($excerpt = strpbrk($text, $this->inlineMarkerList)) { 1066 | 1067 | $marker = $excerpt[0]; 1068 | 1069 | $markerPosition = strpos($text, $marker); 1070 | 1071 | $Excerpt = array('text' => $excerpt, 'context' => $text); 1072 | 1073 | foreach ($this->InlineTypes[$marker] as $inlineType) { 1074 | # check to see if the current inline type is nestable in the current context 1075 | 1076 | if (!empty($nonNestables) && in_array($inlineType, $nonNestables)) { 1077 | continue; 1078 | } 1079 | 1080 | $Inline = $this->{'inline' . $inlineType}($Excerpt); 1081 | 1082 | if (!isset($Inline)) { 1083 | continue; 1084 | } 1085 | 1086 | # makes sure that the inline belongs to "our" marker 1087 | 1088 | if (isset($Inline['position']) and $Inline['position'] > $markerPosition) { 1089 | continue; 1090 | } 1091 | 1092 | # sets a default inline position 1093 | 1094 | if (!isset($Inline['position'])) { 1095 | $Inline['position'] = $markerPosition; 1096 | } 1097 | 1098 | # cause the new element to 'inherit' our non nestables 1099 | foreach ($nonNestables as $non_nestable) { 1100 | $Inline['element']['nonNestables'][] = $non_nestable; 1101 | } 1102 | 1103 | # the text that comes before the inline 1104 | $unmarkedText = substr($text, 0, $Inline['position']); 1105 | 1106 | # compile the unmarked text 1107 | $markup .= $this->unmarkedText($unmarkedText); 1108 | 1109 | # compile the inline 1110 | $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']); 1111 | 1112 | # remove the examined text 1113 | $text = substr($text, $Inline['position'] + $Inline['extent']); 1114 | 1115 | continue 2; 1116 | } 1117 | 1118 | # the marker does not belong to an inline 1119 | 1120 | $unmarkedText = substr($text, 0, $markerPosition + 1); 1121 | 1122 | $markup .= $this->unmarkedText($unmarkedText); 1123 | 1124 | $text = substr($text, $markerPosition + 1); 1125 | } 1126 | 1127 | $markup .= $this->unmarkedText($text); 1128 | 1129 | return $markup; 1130 | } 1131 | 1132 | protected function inlineCode($Excerpt) 1133 | { 1134 | $marker = $Excerpt['text'][0]; 1135 | 1136 | if (preg_match('/^(' . $marker . '+)[ ]*(.+?)[ ]*(? strlen($matches[0]), 1142 | 'element' => array( 1143 | 'name' => 'code', 1144 | 'text' => $text, 1145 | ), 1146 | ); 1147 | } 1148 | } 1149 | 1150 | protected function inlineEmailTag($Excerpt) 1151 | { 1152 | if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches)) { 1153 | $url = $matches[1]; 1154 | 1155 | if (!isset($matches[2])) { 1156 | $url = 'mailto:' . $url; 1157 | } 1158 | 1159 | return array( 1160 | 'extent' => strlen($matches[0]), 1161 | 'element' => array( 1162 | 'name' => 'a', 1163 | 'text' => $matches[1], 1164 | 'attributes' => array( 1165 | 'href' => $url, 1166 | ), 1167 | ), 1168 | ); 1169 | } 1170 | } 1171 | 1172 | protected function inlineEmphasis(array $Excerpt): ?array 1173 | { 1174 | if (!isset($Excerpt['text'][1])) { 1175 | return null; 1176 | } 1177 | 1178 | $marker = $Excerpt['text'][0]; 1179 | 1180 | if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) { 1181 | $emphasis = 'strong'; 1182 | } elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) { 1183 | $emphasis = 'i'; 1184 | } else { 1185 | return null; 1186 | } 1187 | 1188 | return array( 1189 | 'extent' => strlen($matches[0]), 1190 | 'element' => array( 1191 | 'name' => $emphasis, 1192 | 'handler' => 'line', 1193 | 'text' => $matches[1], 1194 | ), 1195 | ); 1196 | } 1197 | 1198 | protected function inlineEscapeSequence(array $Excerpt) 1199 | { 1200 | if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) { 1201 | return array( 1202 | 'markup' => $Excerpt['text'][1], 1203 | 'extent' => 2, 1204 | ); 1205 | } 1206 | } 1207 | 1208 | protected function inlineImage(array $Excerpt) 1209 | { 1210 | if (!isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') { 1211 | return null; 1212 | } 1213 | 1214 | $Excerpt['text'] = substr($Excerpt['text'], 1); 1215 | 1216 | $Link = $this->inlineLink($Excerpt); 1217 | 1218 | if ($Link === null) { 1219 | return null; 1220 | } 1221 | 1222 | $Inline = array( 1223 | 'extent' => $Link['extent'] + 1, 1224 | 'element' => array( 1225 | 'name' => 'img', 1226 | 'attributes' => array( 1227 | 'src' => $Link['element']['attributes']['href'], 1228 | 'alt' => $Link['element']['text'], 1229 | ), 1230 | ), 1231 | ); 1232 | 1233 | $Inline['element']['attributes'] += $Link['element']['attributes']; 1234 | 1235 | unset($Inline['element']['attributes']['href']); 1236 | 1237 | return $Inline; 1238 | } 1239 | 1240 | protected function inlineLink(array $Excerpt) 1241 | { 1242 | $markdownElement = array( 1243 | 'name' => 'a', 1244 | 'handler' => 'line', 1245 | 'nonNestables' => array('Url', 'Link'), 1246 | 'text' => null, 1247 | 'attributes' => array( 1248 | 'href' => null, 1249 | 'title' => null, 1250 | ), 1251 | ); 1252 | 1253 | $extent = 0; 1254 | 1255 | $remainder = $Excerpt['text']; 1256 | 1257 | if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) { 1258 | $markdownElement['text'] = $matches[1]; 1259 | 1260 | $extent += strlen($matches[0]); 1261 | 1262 | $remainder = substr($remainder, $extent); 1263 | } else { 1264 | return null; 1265 | } 1266 | 1267 | if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches)) { 1268 | $markdownElement['attributes']['href'] = $matches[1]; 1269 | 1270 | if (isset($matches[2])) { 1271 | $markdownElement['attributes']['title'] = substr($matches[2], 1, -1); 1272 | } 1273 | 1274 | $extent += strlen($matches[0]); 1275 | } else { 1276 | if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) { 1277 | $definition = strlen($matches[1]) ? $matches[1] : $markdownElement['text']; 1278 | $definition = strtolower($definition); 1279 | 1280 | $extent += strlen($matches[0]); 1281 | } else { 1282 | $definition = strtolower((string) $markdownElement['text']); 1283 | } 1284 | 1285 | if (!isset($this->DefinitionData['Reference'][$definition])) { 1286 | return null; 1287 | } 1288 | 1289 | $Definition = $this->DefinitionData['Reference'][$definition]; 1290 | 1291 | $markdownElement['attributes']['href'] = $Definition['url']; 1292 | $markdownElement['attributes']['title'] = $Definition['title']; 1293 | } 1294 | 1295 | return array( 1296 | 'extent' => $extent, 1297 | 'element' => $markdownElement, 1298 | ); 1299 | } 1300 | 1301 | protected function inlineMarkup($Excerpt) 1302 | { 1303 | if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) { 1304 | return null; 1305 | } 1306 | 1307 | if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*[ ]*>/s', $Excerpt['text'], $matches)) { 1308 | return array( 1309 | 'markup' => $matches[0], 1310 | 'extent' => strlen($matches[0]), 1311 | ); 1312 | } 1313 | 1314 | if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) { 1315 | return array( 1316 | 'markup' => $matches[0], 1317 | 'extent' => strlen($matches[0]), 1318 | ); 1319 | } 1320 | 1321 | if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*(?:[ ]*' . $this->regexHtmlAttribute . ')*[ ]*\/?>/s', $Excerpt['text'], $matches)) { 1322 | return array( 1323 | 'markup' => $matches[0], 1324 | 'extent' => strlen($matches[0]), 1325 | ); 1326 | } 1327 | } 1328 | 1329 | protected function inlineSpecialCharacter($Excerpt) 1330 | { 1331 | if ($Excerpt['text'][0] === '&' and !preg_match('/^&#?\w+;/', $Excerpt['text'])) { 1332 | return array( 1333 | 'markup' => '&', 1334 | 'extent' => 1, 1335 | ); 1336 | } 1337 | 1338 | $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot'); 1339 | 1340 | if (isset($SpecialCharacter[$Excerpt['text'][0]])) { 1341 | return array( 1342 | 'markup' => '&' . $SpecialCharacter[$Excerpt['text'][0]] . ';', 1343 | 'extent' => 1, 1344 | ); 1345 | } 1346 | } 1347 | 1348 | protected function inlineStrikethrough($Excerpt) 1349 | { 1350 | if (!isset($Excerpt['text'][1])) { 1351 | return null; 1352 | } 1353 | 1354 | if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) { 1355 | return array( 1356 | 'extent' => strlen($matches[0]), 1357 | 'element' => array( 1358 | 'name' => 'del', 1359 | 'text' => $matches[1], 1360 | 'handler' => 'line', 1361 | ), 1362 | ); 1363 | } 1364 | } 1365 | 1366 | protected function inlineUrl($Excerpt): array|null 1367 | { 1368 | if ($this->urlsLinked !== true or !isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') { 1369 | return null; 1370 | } 1371 | 1372 | if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)) { 1373 | $url = $matches[0][0]; 1374 | 1375 | $Inline = array( 1376 | 'extent' => strlen($matches[0][0]), 1377 | 'position' => $matches[0][1], 1378 | 'element' => array( 1379 | 'name' => 'a', 1380 | 'text' => $url, 1381 | 'attributes' => array( 1382 | 'href' => $url, 1383 | ), 1384 | ), 1385 | ); 1386 | 1387 | return $Inline; 1388 | } 1389 | 1390 | return null; 1391 | } 1392 | 1393 | protected function inlineUrlTag($Excerpt): array|null 1394 | { 1395 | if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches)) { 1396 | $url = $matches[1]; 1397 | 1398 | return array( 1399 | 'extent' => strlen($matches[0]), 1400 | 'element' => array( 1401 | 'name' => 'a', 1402 | 'text' => $url, 1403 | 'attributes' => array( 1404 | 'href' => $url, 1405 | ), 1406 | ), 1407 | ); 1408 | } 1409 | 1410 | return null; 1411 | } 1412 | 1413 | protected function unmarkedText($text): array|string|null 1414 | { 1415 | if ($this->breaksEnabled) { 1416 | 1417 | $text = preg_replace('/[ ]*\n/', "
\n", $text); 1418 | 1419 | } else { 1420 | $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "
\n", $text); 1421 | $text = str_replace(" \n", "\n", $text); 1422 | } 1423 | 1424 | return $text; 1425 | } 1426 | 1427 | protected function element(array $markdownElement) 1428 | { 1429 | if ($this->safeMode) { 1430 | $markdownElement = $this->sanitisemarkdownElement($markdownElement); 1431 | } 1432 | 1433 | $markup = '<' . $markdownElement['name']; 1434 | 1435 | if (isset($markdownElement['attributes'])) { 1436 | foreach ($markdownElement['attributes'] as $name => $value) { 1437 | if ($value === null) { 1438 | continue; 1439 | } 1440 | 1441 | $markup .= ' ' . $name . '="' . self::escape($value) . '"'; 1442 | } 1443 | } 1444 | 1445 | $permitRawHtml = false; 1446 | 1447 | if (isset($markdownElement['text'])) { 1448 | $text = $markdownElement['text']; 1449 | } 1450 | // very strongly consider an alternative if you're writing an 1451 | // extension 1452 | elseif (isset($markdownElement['rawHtml'])) { 1453 | $text = $markdownElement['rawHtml']; 1454 | $allowRawHtmlInSafeMode = isset($markdownElement['allowRawHtmlInSafeMode']) && $markdownElement['allowRawHtmlInSafeMode']; 1455 | $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; 1456 | } 1457 | 1458 | if (isset($text)) { 1459 | $markup .= '>'; 1460 | 1461 | if (!isset($markdownElement['nonNestables'])) { 1462 | $markdownElement['nonNestables'] = []; 1463 | } 1464 | 1465 | if (isset($markdownElement['handler'])) { 1466 | $markup .= $this->{$markdownElement['handler']}($text, $markdownElement['nonNestables']); 1467 | } elseif (!$permitRawHtml) { 1468 | $markup .= self::escape($text, true); 1469 | } else { 1470 | $markup .= $text; 1471 | } 1472 | 1473 | $markup .= ''; 1474 | } else { 1475 | $markup .= ' />'; 1476 | } 1477 | 1478 | return $markup; 1479 | } 1480 | 1481 | protected function elements(array $markdownElements): string 1482 | { 1483 | $markup = ''; 1484 | 1485 | foreach ($markdownElements as $markdownElement) { 1486 | $markup .= "\n" . $this->element($markdownElement); 1487 | } 1488 | 1489 | $markup .= "\n"; 1490 | 1491 | return $markup; 1492 | } 1493 | 1494 | protected function li($lines): ?string 1495 | { 1496 | $markup = $this->lines($lines); 1497 | 1498 | $trimmedMarkup = trim($markup); 1499 | 1500 | if (!in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '

') { 1501 | $markup = $trimmedMarkup; 1502 | $markup = substr($markup, 3); 1503 | 1504 | $position = strpos($markup, "

"); 1505 | 1506 | $markup = substr_replace($markup, '', $position, 4); 1507 | } 1508 | 1509 | return $markup; 1510 | } 1511 | 1512 | /** 1513 | * Replace occurrences $regexp with $Elements in $text. Return an array of 1514 | * elements representing the replacement. 1515 | */ 1516 | protected static function pregReplaceElements(string $regexp, array $Elements, string $text): array 1517 | { 1518 | $newElements = []; 1519 | 1520 | while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) { 1521 | $offset = $matches[0][1]; 1522 | $before = substr($text, 0, $offset); 1523 | $after = substr($text, $offset + strlen($matches[0][0])); 1524 | 1525 | $newElements[] = ['text' => $before]; 1526 | 1527 | foreach ($Elements as $Element) { 1528 | $newElements[] = $Element; 1529 | } 1530 | 1531 | $text = $after; 1532 | } 1533 | 1534 | $newElements[] = ['text' => $text]; 1535 | 1536 | return $newElements; 1537 | } 1538 | 1539 | /** 1540 | * Inline Text 1541 | * 1542 | * @param string $text 1543 | * 1544 | * @return array 1545 | */ 1546 | protected function inlineText(string $text): array 1547 | { 1548 | $Inline = [ 1549 | 'extent' => strlen($text), 1550 | 'element' => array(), 1551 | ]; 1552 | 1553 | $Inline['element']['elements'] = self::pregReplaceElements( 1554 | $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', 1555 | [ 1556 | array('name' => 'br'), 1557 | array('text' => "\n"), 1558 | ], 1559 | $text 1560 | ); 1561 | 1562 | return $Inline; 1563 | } 1564 | 1565 | protected function handleElementRecursive(array $Element): mixed 1566 | { 1567 | return $this->elementApplyRecursive(array($this, 'handle'), $Element); 1568 | } 1569 | 1570 | protected function handleElementsRecursive(array $Elements): array 1571 | { 1572 | return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); 1573 | } 1574 | 1575 | protected function elementApplyRecursive($closure, array $Element): mixed 1576 | { 1577 | $Element = call_user_func($closure, $Element); 1578 | 1579 | if (isset($Element['elements'])) { 1580 | $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); 1581 | } elseif (isset($Element['element'])) { 1582 | $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); 1583 | } 1584 | 1585 | return $Element; 1586 | } 1587 | 1588 | protected function elementsApplyRecursive($closure, array $Elements): array 1589 | { 1590 | foreach ($Elements as &$Element) { 1591 | $Element = $this->elementApplyRecursive($closure, $Element); 1592 | } 1593 | 1594 | return $Elements; 1595 | } 1596 | 1597 | protected function elementsApplyRecursiveDepthFirst($closure, array $Elements): array 1598 | { 1599 | foreach ($Elements as &$Element) { 1600 | $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); 1601 | } 1602 | 1603 | return $Elements; 1604 | } 1605 | 1606 | protected function elementApplyRecursiveDepthFirst($closure, array $Element) 1607 | { 1608 | if (isset($Element['elements'])) { 1609 | $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); 1610 | } elseif (isset($Element['element'])) { 1611 | $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); 1612 | } 1613 | 1614 | $Element = call_user_func($closure, $Element); 1615 | 1616 | return $Element; 1617 | } 1618 | 1619 | protected function sanitisemarkdownElement(array $markdownElement): array 1620 | { 1621 | static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; 1622 | static $safeUrlNameToAtt = [ 1623 | 'a' => 'href', 1624 | 'img' => 'src', 1625 | ]; 1626 | 1627 | if (isset($safeUrlNameToAtt[$markdownElement['name']])) { 1628 | $markdownElement = $this->filterUnsafeUrlInAttribute($markdownElement, $safeUrlNameToAtt[$markdownElement['name']]); 1629 | } 1630 | 1631 | if (!empty($markdownElement['attributes'])) { 1632 | 1633 | foreach ($markdownElement['attributes'] as $att => $val) { 1634 | # filter out badly parsed attribute 1635 | if (!preg_match($goodAttribute, $att)) { 1636 | unset($markdownElement['attributes'][$att]); 1637 | } 1638 | # dump onevent attribute 1639 | elseif (self::striAtStart($att, 'on')) { 1640 | unset($markdownElement['attributes'][$att]); 1641 | } 1642 | } 1643 | } 1644 | 1645 | return $markdownElement; 1646 | } 1647 | 1648 | protected function filterUnsafeUrlInAttribute(array $markdownElement, $attribute): array 1649 | { 1650 | foreach ($this->safeLinksWhitelist as $scheme) { 1651 | 1652 | if (self::striAtStart($markdownElement['attributes'][$attribute], $scheme)) { 1653 | return $markdownElement; 1654 | } 1655 | } 1656 | 1657 | $markdownElement['attributes'][$attribute] = str_replace(':', '%3A', $markdownElement['attributes'][$attribute]); 1658 | 1659 | return $markdownElement; 1660 | } 1661 | 1662 | protected function textElements($text): array 1663 | { 1664 | # make sure no definitions are set 1665 | $this->DefinitionData = []; 1666 | 1667 | # standardize line breaks 1668 | $text = str_replace(array("\r\n", "\r"), "\n", $text); 1669 | 1670 | # remove surrounding line breaks 1671 | $text = trim($text, "\n"); 1672 | 1673 | # split text into lines 1674 | $lines = explode("\n", $text); 1675 | 1676 | # iterate through lines to identify blocks 1677 | return $this->linesElements($lines); 1678 | } 1679 | 1680 | protected $unmarkedBlockTypes; 1681 | protected $BlockTypes; 1682 | 1683 | protected function linesElements(array $lines): array 1684 | { 1685 | $Elements = []; 1686 | $CurrentBlock = null; 1687 | 1688 | foreach ($lines as $line) { 1689 | if (chop($line) === '') { 1690 | if (isset($CurrentBlock)) { 1691 | $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted']) 1692 | ? $CurrentBlock['interrupted'] + 1 1693 | : 1 1694 | ); 1695 | } 1696 | continue; 1697 | } 1698 | 1699 | while (($beforeTab = strstr($line, "\t", true)) !== false) { 1700 | $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; 1701 | 1702 | $line = $beforeTab 1703 | . str_repeat(' ', $shortage) 1704 | . substr($line, strlen($beforeTab) + 1) 1705 | ; 1706 | } 1707 | 1708 | $indent = strspn($line, ' '); 1709 | $text = $indent > 0 ? substr($line, $indent) : $line; 1710 | $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); 1711 | 1712 | if (isset($CurrentBlock['continuable'])) { 1713 | $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; 1714 | $Block = $this->$methodName($Line, $CurrentBlock); 1715 | 1716 | if (isset($Block)) { 1717 | $CurrentBlock = $Block; 1718 | 1719 | continue; 1720 | } else { 1721 | $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; 1722 | $CurrentBlock = $this->$methodName($CurrentBlock); 1723 | } 1724 | } 1725 | 1726 | $marker = $text[0]; 1727 | $blockTypes = $this->unmarkedBlockTypes; 1728 | 1729 | if (isset($this->BlockTypes[$marker])) { 1730 | foreach ($this->BlockTypes[$marker] as $blockType) { 1731 | $blockTypes[] = $blockType; 1732 | } 1733 | } 1734 | 1735 | foreach ($blockTypes as $blockType) { 1736 | $Block = $this->{"block$blockType"}($Line, $CurrentBlock); 1737 | 1738 | if (isset($Block)) { 1739 | $Block['type'] = $blockType; 1740 | 1741 | if (!isset($Block['identified'])) { 1742 | if (isset($CurrentBlock)) { 1743 | $Elements[] = $this->extractElement($CurrentBlock); 1744 | } 1745 | 1746 | $Block['identified'] = true; 1747 | } 1748 | 1749 | if ($this->isBlockContinuable($blockType)) { 1750 | $Block['continuable'] = true; 1751 | } 1752 | 1753 | $CurrentBlock = $Block; 1754 | 1755 | continue 2; 1756 | } 1757 | } 1758 | 1759 | if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') { 1760 | $Block = $this->paragraphContinue($Line, $CurrentBlock); 1761 | } 1762 | 1763 | if (isset($Block)) { 1764 | $CurrentBlock = $Block; 1765 | } else { 1766 | if (isset($CurrentBlock)) { 1767 | $Elements[] = $this->extractElement($CurrentBlock); 1768 | } 1769 | 1770 | $CurrentBlock = $this->paragraph($Line); 1771 | 1772 | $CurrentBlock['identified'] = true; 1773 | } 1774 | } 1775 | 1776 | if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) { 1777 | $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; 1778 | $CurrentBlock = $this->$methodName($CurrentBlock); 1779 | } 1780 | 1781 | if (isset($CurrentBlock)) { 1782 | $Elements[] = $this->extractElement($CurrentBlock); 1783 | } 1784 | 1785 | return $Elements; 1786 | } 1787 | 1788 | protected function isBlockContinuable($Type): bool 1789 | { 1790 | return method_exists($this, "block{$Type}Continue"); 1791 | } 1792 | 1793 | protected function isBlockCompletable($Type): bool 1794 | { 1795 | return method_exists($this, "block{$Type}Complete"); 1796 | } 1797 | 1798 | protected function extractElement(array $Component) 1799 | { 1800 | if ( ! isset($Component['element'])) { 1801 | if (isset($Component['markup'])) { 1802 | $Component['element'] = ['rawHtml' => $Component['markup']]; 1803 | } elseif (isset($Component['hidden'])) { 1804 | $Component['element'] = []; 1805 | } 1806 | } 1807 | 1808 | return $Component['element']; 1809 | } 1810 | 1811 | protected function paragraphContinue($Line, array $Block): array 1812 | { 1813 | if (isset($Block['interrupted'])) return []; 1814 | 1815 | $Block['element']['handler']['argument'] .= "\n".$Line['text']; 1816 | 1817 | return $Block; 1818 | } 1819 | 1820 | protected static function escape($text, $allowQuotes = false): string 1821 | { 1822 | return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); 1823 | } 1824 | 1825 | protected static function striAtStart($string, $needle) 1826 | { 1827 | $len = strlen($needle); 1828 | 1829 | if ($len > strlen($string)) { 1830 | return false; 1831 | } else { 1832 | return strtolower(substr($string, 0, $len)) === strtolower($needle); 1833 | } 1834 | } 1835 | 1836 | protected static function instance($name = 'default') 1837 | { 1838 | if (isset(self::$instances[$name])) { 1839 | return self::$instances[$name]; 1840 | } 1841 | 1842 | $instance = new static(); 1843 | self::$instances[$name] = $instance; 1844 | 1845 | return $instance; 1846 | } 1847 | } -------------------------------------------------------------------------------- /src/Markdown.php: -------------------------------------------------------------------------------- 1 | contents[]['multi-line'] = $content; 41 | return $this; 42 | } 43 | 44 | /** 45 | * Set Inline Markdown Contents 46 | * 47 | * @param string $content markdown contents 48 | * 49 | * @return self 50 | */ 51 | public function setInlineContent(string $content): static 52 | { 53 | $this->contents[]['inline'] = $content; 54 | return $this; 55 | } 56 | 57 | /** 58 | * Set Markdown File 59 | * 60 | * @param string $file_name: Set File to read markdown content from e.g './markdowns/index.md' 61 | * 62 | * @return self 63 | */ 64 | public function setFile(string $file_name): static 65 | { 66 | $this->contents[]['file'] = $file_name; 67 | return $this; 68 | } 69 | 70 | /** 71 | * Set directory where compiled markdown files will be stored in html format 72 | * 73 | * @param string $directory directory where your compiled html files will be stored 74 | */ 75 | public function setCompileDir(string $directory = './markdowns/'): static 76 | { 77 | try { 78 | $compilationDir = !str_ends_with($directory, '/') 79 | ? "$directory/" 80 | : $directory; 81 | 82 | $this->compileDir[] = $compilationDir; 83 | 84 | if (!is_dir($compilationDir)) { 85 | if (mkdir($compilationDir, 0777)) { 86 | return $this; 87 | } 88 | } 89 | return $this; 90 | } catch (\Exception|\TypeError|\Throwable $e) { 91 | throw $e; 92 | } 93 | } 94 | 95 | /** 96 | * Read File Contents 97 | * 98 | * @param string $filename Input file name 99 | * 100 | * @return string|\Exception|null 101 | */ 102 | private function read_file(string $filename): string|\Exception|null 103 | { 104 | if (!file_exists($filename)) { 105 | $filename = !str_starts_with($filename, '/') 106 | ? "/{$filename}" 107 | : $filename; 108 | 109 | if (!file_exists($filename)) { 110 | throw new \Exception("File Name or Directory ($filename) Does Not Exist!"); 111 | } 112 | } 113 | 114 | return file_get_contents($filename); 115 | } 116 | 117 | /** 118 | * Single Lined Markdown Converter 119 | * 120 | * @return ?string 121 | */ 122 | private function compileSingleLinedMarkdown(string $markdown): ?string 123 | { 124 | $instance = new ParseMarkdown( 125 | $this->sanitize 126 | ); 127 | 128 | return $instance->line($markdown); 129 | } 130 | 131 | /** 132 | * Multi-Lined Markdown Converter 133 | * 134 | * @return ?string 135 | */ 136 | private function compileMultiLinedMarkdown(string $markdown): ?string 137 | { 138 | $instance = new ParseMarkdown( 139 | $this->sanitize 140 | ); 141 | 142 | return $instance->markdown_text($markdown); 143 | } 144 | 145 | /** 146 | * Check if File Name is Valid 147 | */ 148 | private function validateFileName(string $name): \InvalidArgumentException|bool 149 | { 150 | $validateType = preg_match('/(^\s+)/', $name); 151 | 152 | # check if file name is valid and acceptable 153 | if ($validateType) { 154 | throw new \InvalidArgumentException('File Name Must Be A Valid String!'); 155 | } 156 | 157 | return true; 158 | } 159 | 160 | /** 161 | * Add html extension to file name 162 | * 163 | * @param string $file_name replace default output filename 164 | * 165 | * @return ?string 166 | */ 167 | private function addHtmlExtension(string $file_name): ?string 168 | { 169 | return !str_ends_with($file_name, '.html') 170 | ? "{$file_name}.html" 171 | : $file_name; 172 | } 173 | 174 | /** 175 | * Compile Markdown to Raw HTML Output 176 | * 177 | * @throws \LogicException 178 | */ 179 | public function toHtml(): ?string 180 | { 181 | if (!isset($this->contents) || count($this->contents) == 0) { 182 | throw new \LogicException( 183 | message: 'Set a Markdown Content or File Before Conversion!' 184 | ); 185 | } 186 | 187 | // store all compiled html contents here 188 | $html_contents = []; 189 | 190 | foreach ($this->contents as $key => $single_content) { 191 | $html_contents[] = match (array_key_first($single_content)) { 192 | 'inline' => $this->compileSingleLinedMarkdown($single_content['inline']), 193 | 'file' => $this->compileMultiLinedMarkdown($this->read_file($single_content['file'])), 194 | default => $this->compileMultiLinedMarkdown($single_content['multi-line']) 195 | }; 196 | }; 197 | 198 | return implode("\n\r", $html_contents); 199 | } 200 | 201 | 202 | /** 203 | * Compile Markdown Contents to Html File 204 | * 205 | * @param string $file_name: rename compiled html file 206 | * 207 | * @return bool|\LogicException 208 | */ 209 | public function toHtmlFile(string $file_name = 'compiledmarkdown.html'): \LogicException|bool 210 | { 211 | // validate file name 212 | $this->validateFileName($file_name); 213 | 214 | // check if compilation directories are set 215 | if (!isset($this->compileDir) || count($this->compileDir) == 0) { 216 | throw new \LogicException('Ensure To Set A Storage Directory For Your Compiled HTML File!'); 217 | } 218 | 219 | $html_contents = []; 220 | 221 | if (isset($this->contents) && count($this->contents) > 0) { 222 | foreach ($this->contents as $key => $single_content) { 223 | $html_contents[] = match (array_key_first($single_content)) { 224 | 'inline' => $this->compileSingleLinedMarkdown($single_content['inline']), 225 | 'file' => $this->compileMultiLinedMarkdown($this->read_file($single_content['file'])), 226 | default => $this->compileMultiLinedMarkdown($single_content['multi-line']) 227 | }; 228 | }; 229 | 230 | # add extension to filename 231 | $file_name = $this->addHtmlExtension($file_name); 232 | 233 | // Compile The Markdown Contents to Single pr Multiple Directories 234 | return $this->saveCompiledMarkdownFiles( 235 | compileDirs: $this->compileDir, 236 | file_name: $file_name, 237 | contents: $html_contents 238 | ); 239 | } 240 | 241 | throw new \LogicException('Set A Markdown File or Content to Compile!'); 242 | } 243 | 244 | private function saveCompiledMarkdownFiles(array $compileDirs, string $file_name, array $contents): bool 245 | { 246 | if (count($compileDirs) > 0) { 247 | foreach ($compileDirs as $single_directory) { 248 | if (!is_dir($single_directory)) { 249 | throw new \RuntimeException("Failed To Locate ('{$single_directory}') Directory!"); 250 | } 251 | 252 | # write md to html file 253 | if ($create_file = fopen("{$single_directory}{$file_name}", 'w+')) { 254 | fwrite($create_file, implode("\n\r", $contents)); 255 | fclose($create_file); 256 | continue; 257 | } 258 | } 259 | return true; 260 | } 261 | return false; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /tests/CompilationTest.php: -------------------------------------------------------------------------------- 1 | setFile(file_name: __DIR__ . '/files/hello.md') 21 | ->toHtml(); 22 | 23 | $this->assertSame(expected: '

hello 1

', actual: $markdown); 24 | } 25 | 26 | 27 | /** 28 | * Test Markdown to Html Conversion 29 | * 30 | * @return void 31 | */ 32 | public function testMdtoHtml(): void 33 | { 34 | $markdown = Markdown::new() 35 | ->setInlineContent('_This is an inline markdown content_') 36 | ->toHtml(); //

hello 1

37 | 38 | $this->assertSame(expected: 'This is an inline markdown content', actual: $markdown); 39 | } 40 | 41 | 42 | /** 43 | * Test Markdown File to Html File Conversion 44 | * 45 | * @return void 46 | */ 47 | public function testMdFiletoHtmlFile(): void 48 | { 49 | $markdown = Markdown::new() 50 | ->setFile(file_name: __DIR__ . '/files/hello-2.md') 51 | ->setCompileDir(directory: './pages/') 52 | ->toHtmlFile(file_name: 'hello-2.html'); //

hello 2

53 | 54 | $this->assertIsBool(actual: $markdown); 55 | $this->assertTrue(condition: $markdown); 56 | } 57 | 58 | 59 | /** 60 | * Test Markdown Content to Html File Conversion 61 | * 62 | * @return void 63 | */ 64 | public function testMdContentToHtmlFile(): void 65 | { 66 | $markdown = Markdown::new() 67 | ->setContent(content: '### hello 3') 68 | ->setCompileDir(directory: './pages/') 69 | ->toHtmlFile(file_name: 'hello-3.html'); //

hello 3

70 | 71 | $this->assertIsBool(actual: $markdown); 72 | 73 | $this->assertTrue(condition: $markdown); 74 | } 75 | 76 | 77 | /** 78 | * Test Markdown Compilation 79 | * 80 | * @return void 81 | */ 82 | public function testMarkdownCompilation(): void 83 | { 84 | $markdown = Markdown::new() 85 | ->setContent('# Title') 86 | ->setContent('# Sub-Title') 87 | ->setInlineContent('_first word with_') 88 | ->setInlineContent('[A LINK](https://github.com/fastvolt)') 89 | ->toHtml(); 90 | 91 | $this->assertIsString($markdown); 92 | } 93 | 94 | /** 95 | * Test Markdown Compilation 96 | * 97 | * @return void 98 | */ 99 | public function testMarkdownAdvancedCompilation(): void 100 | { 101 | $markdown = Markdown::new(sanitize: true) 102 | ->setFile(__DIR__ . '/files/heading.md') 103 | ->setInlineContent('_My name is **vincent**, the co-author of this blog_') 104 | ->setContent('Kindly follow me on my github page via: [@vincent](https://github.com/oladoyinbov).') 105 | ->setContent('Here are the lists of my projects:') 106 | ->setContent(' 107 | - Dragon CMS 108 | - Fastvolt Framework. 109 | + Fastvolt Router 110 | + Markdown Parser. 111 | ') 112 | ->setFile(__DIR__ . '/files/footer.md'); 113 | 114 | 115 | // set compilation directory 116 | $markdown->setCompileDir('./pages/'); 117 | 118 | // set second compilation directory (OPTIONAL) 119 | $markdown->setCompileDir('./pages/backup/'); 120 | 121 | // Compile The Markdown with File Name 'homepage' 122 | $result = $markdown->toHtmlFile(file_name: 'homepage'); 123 | 124 | $this->assertIsBool($result); 125 | 126 | $this->assertTrue($result === true); 127 | } 128 | } -------------------------------------------------------------------------------- /tests/MarkupTest.php: -------------------------------------------------------------------------------- 1 | 13 | * 14 | * @return void 15 | */ 16 | public function testHeading1(): void 17 | { 18 | $markdown = Markdown::new() 19 | ->setContent('# hello world') 20 | ->toHtml(); //

hello 1

21 | 22 | $this->assertSame('

hello world

', $markdown); 23 | } 24 | 25 | /** 26 | * Heading Test 2:

27 | * 28 | * @return void 29 | */ 30 | public function testHeading2(): void 31 | { 32 | $markdown = Markdown::new() 33 | ->setContent('## hello world') 34 | ->toHtml(); //

hello 1

35 | 36 | $this->assertSame('

hello world

', $markdown); 37 | } 38 | 39 | /** 40 | * Heading Test 3:

41 | * 42 | * @return void 43 | */ 44 | public function testHeading3(): void 45 | { 46 | $markdown = Markdown::new() 47 | ->setContent('### hello world') 48 | ->toHtml(); //

hello 1

49 | 50 | $this->assertSame('

hello world

', $markdown); 51 | } 52 | 53 | /** 54 | * Heading Test 4:

55 | * 56 | * @return void 57 | */ 58 | public function testHeading4(): void 59 | { 60 | $markdown = Markdown::new() 61 | ->setContent('#### hello world') 62 | ->toHtml(); //

hello 1

63 | 64 | $this->assertSame('

hello world

', $markdown); 65 | } 66 | 67 | /** 68 | * Test Italic 1: 69 | * 70 | * @return void 71 | */ 72 | public function testItalic(): void 73 | { 74 | $markdown = Markdown::new() 75 | ->setInlineContent('*hello world*') 76 | ->toHtml(); // hello 1 77 | 78 | $this->assertSame('hello world', $markdown); 79 | } 80 | 81 | /** 82 | * Test Link 1: 83 | * 84 | * @return void 85 | */ 86 | public function testHyperLink(): void 87 | { 88 | $markdown = Markdown::new() 89 | ->setInlineContent('[A LINK](https://github.com/fastvolt)') 90 | ->toHtml(); // hello 1 91 | 92 | $this->assertSame('A LINK', $markdown); 93 | } 94 | 95 | 96 | /** 97 | * Test Inline Markdown Compilation 98 | * 99 | * @return void 100 | */ 101 | public function testInlineMarkdownCompilation(): void 102 | { 103 | $markdown = Markdown::new() 104 | ->setInlineContent('**hello world with _emphasis_**') 105 | ->toHtml(); 106 | 107 | $this->assertSame('hello world with emphasis', $markdown); 108 | } 109 | 110 | /** 111 | * Test Multi-lined Markdown Compilation 112 | * 113 | * @return void 114 | */ 115 | public function testMultiLinedMarkdownCompilation(): void 116 | { 117 | $markdown = Markdown::new(false) 118 | ->setInlineContent('> first world') 119 | ->setInlineContent('__second word__') 120 | ->toHtml(); 121 | 122 | $this->assertNotNull($markdown); 123 | $this->assertIsString($markdown); 124 | } 125 | 126 | /** 127 | * Test Markdown Compilation With Sanitization Off 128 | * 129 | * @return void 130 | */ 131 | public function testMarkdownSanitization(): void 132 | { 133 | $markdown = Markdown::new(sanitize: true) 134 | ->setInlineContent('

first word

') 135 | ->toHtml(); 136 | 137 | $this->assertSame('<p>first word</p>', $markdown); 138 | } 139 | } -------------------------------------------------------------------------------- /tests/files/footer.md: -------------------------------------------------------------------------------- 1 | ### Thanks for Visiting My BlogPage -------------------------------------------------------------------------------- /tests/files/heading.md: -------------------------------------------------------------------------------- 1 | # Blog Title 2 | ### Here is the Blog Sub-title -------------------------------------------------------------------------------- /tests/files/hello-2.md: -------------------------------------------------------------------------------- 1 | ## hello 99 -------------------------------------------------------------------------------- /tests/files/hello.md: -------------------------------------------------------------------------------- 1 | # hello 1 -------------------------------------------------------------------------------- /tests/pages/backup/homepage.html: -------------------------------------------------------------------------------- 1 |

Blog Title

2 |

Here is the Blog Sub-title

3 | My name is vincent, the co-author of this blog 4 |

Kindly follow me on my github page via: @vincent.

5 |

Here are the lists of my projects:

6 | 14 |

Thanks for Visiting My BlogPage

-------------------------------------------------------------------------------- /tests/pages/compiledmarkdown.html: -------------------------------------------------------------------------------- 1 |

hello

-------------------------------------------------------------------------------- /tests/pages/hello-2.html: -------------------------------------------------------------------------------- 1 |

hello 2

-------------------------------------------------------------------------------- /tests/pages/hello-3.html: -------------------------------------------------------------------------------- 1 |

hello 3

-------------------------------------------------------------------------------- /tests/pages/homepage.html: -------------------------------------------------------------------------------- 1 |

Blog Title

2 |

Here is the Blog Sub-title

3 | My name is vincent, the co-author of this blog 4 |

Kindly follow me on my github page via: @vincent.

5 |

Here are the lists of my projects:

6 | 14 |

Thanks for Visiting My BlogPage

--------------------------------------------------------------------------------