├── .github └── workflows │ └── php.yml ├── .gitignore ├── .php-cs-fixer.cache ├── README.md ├── composer.json ├── src ├── Markdown.php └── libs │ └── Markdown │ └── Process │ └── ParseMarkdown.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 | # 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 -------------------------------------------------------------------------------- /.php-cs-fixer.cache: -------------------------------------------------------------------------------- 1 | {"php":"8.2.4","version":"3.38.0:v3.38.0#7e6070026e76aa09d77a47519625c86593fb8e31","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_parentheses":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":true,"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true},"hashes":{"src\\Markdown\\Process\\ParseMarkdown.php":"ea7d2718ded93ec95dbf29af6cb74914","src\\Markdown.php":"799732b8f3599c14aecfb7df3793ec04","src\\Markdown\\Process\\MarkdownAdvanced.php":"ccf702f4663b9fe31a37d46fb4bc3762","src\\Markdown\\Process\\ParseDownNewer.php":"ab70177ff2c798a2e8735a8161b12c8c","src\\Markdown\\Process\\ParseMardown.php":"bef068084f6dc74453b09a4396bbc608","src\\Markdown\\Process\\ParseMarkdownold.php":"178c9d47230c0eeba985124ccd641f68"}} -------------------------------------------------------------------------------- /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 | ```auto 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 = 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 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 | > ***index.php:*** 84 | 85 | ```php 86 | $markdown = Markdown::new(); 87 | 88 | // set markdown file to parse 89 | $markdown->setFile('./markdowns/sample.md'); 90 | 91 | // compile as raw HTML 92 | echo $markdown->toHtml(); 93 | ``` 94 | 95 | > ***Output:*** 96 | 97 | ```html 98 |

Heading 4

99 |

Heading 3

100 |

Heading 2

101 |

Heading 1

102 | 106 |

THIS IS A BLOCKQUOTE

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

Hello World

'); 148 | 149 | echo $markdown->toHtml(); 150 | ``` 151 | 152 | > ***Output:*** 153 | 154 | ```html 155 |

<h1>Hello World</h1>

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

Blog Title

229 |

Here is the Blog Sub-title

230 | My name is vincent, the co-author of this blog 231 |

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

232 |

Here are the lists of my projects:

233 | 242 |

Thanks for Visiting My BlogPage

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

Heading 2

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

') { 1505 | $markup = $trimmedMarkup; 1506 | $markup = substr($markup, 3); 1507 | 1508 | $position = strpos($markup, "

"); 1509 | 1510 | $markup = substr_replace($markup, '', $position, 4); 1511 | } 1512 | 1513 | return $markup; 1514 | } 1515 | 1516 | /** 1517 | * Replace occurrences $regexp with $Elements in $text. Return an array of 1518 | * elements representing the replacement. 1519 | */ 1520 | protected static function pregReplaceElements(string $regexp, array $Elements, string $text): array 1521 | { 1522 | $newElements = []; 1523 | 1524 | while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) { 1525 | $offset = $matches[0][1]; 1526 | $before = substr($text, 0, $offset); 1527 | $after = substr($text, $offset + strlen($matches[0][0])); 1528 | 1529 | $newElements[] = ['text' => $before]; 1530 | 1531 | foreach ($Elements as $Element) { 1532 | $newElements[] = $Element; 1533 | } 1534 | 1535 | $text = $after; 1536 | } 1537 | 1538 | $newElements[] = ['text' => $text]; 1539 | 1540 | return $newElements; 1541 | } 1542 | 1543 | /** 1544 | * Inline Text 1545 | * 1546 | * @param string $text 1547 | * 1548 | * @return array 1549 | */ 1550 | protected function inlineText(string $text): array 1551 | { 1552 | $Inline = [ 1553 | 'extent' => strlen($text), 1554 | 'element' => array(), 1555 | ]; 1556 | 1557 | $Inline['element']['elements'] = self::pregReplaceElements( 1558 | $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', 1559 | [ 1560 | array('name' => 'br'), 1561 | array('text' => "\n"), 1562 | ], 1563 | $text 1564 | ); 1565 | 1566 | return $Inline; 1567 | } 1568 | 1569 | protected function handleElementRecursive(array $Element): mixed 1570 | { 1571 | return $this->elementApplyRecursive(array($this, 'handle'), $Element); 1572 | } 1573 | 1574 | protected function handleElementsRecursive(array $Elements): array 1575 | { 1576 | return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); 1577 | } 1578 | 1579 | protected function elementApplyRecursive($closure, array $Element): mixed 1580 | { 1581 | $Element = call_user_func($closure, $Element); 1582 | 1583 | if (isset($Element['elements'])) { 1584 | $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); 1585 | } elseif (isset($Element['element'])) { 1586 | $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); 1587 | } 1588 | 1589 | return $Element; 1590 | } 1591 | 1592 | protected function elementsApplyRecursive($closure, array $Elements): array 1593 | { 1594 | foreach ($Elements as &$Element) { 1595 | $Element = $this->elementApplyRecursive($closure, $Element); 1596 | } 1597 | 1598 | return $Elements; 1599 | } 1600 | 1601 | protected function elementsApplyRecursiveDepthFirst($closure, array $Elements): array 1602 | { 1603 | foreach ($Elements as &$Element) { 1604 | $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); 1605 | } 1606 | 1607 | return $Elements; 1608 | } 1609 | 1610 | protected function elementApplyRecursiveDepthFirst($closure, array $Element) 1611 | { 1612 | if (isset($Element['elements'])) { 1613 | $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); 1614 | } elseif (isset($Element['element'])) { 1615 | $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); 1616 | } 1617 | 1618 | $Element = call_user_func($closure, $Element); 1619 | 1620 | return $Element; 1621 | } 1622 | 1623 | protected function sanitisemarkdownElement(array $markdownElement): array 1624 | { 1625 | static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; 1626 | static $safeUrlNameToAtt = [ 1627 | 'a' => 'href', 1628 | 'img' => 'src', 1629 | ]; 1630 | 1631 | if (isset($safeUrlNameToAtt[$markdownElement['name']])) { 1632 | $markdownElement = $this->filterUnsafeUrlInAttribute($markdownElement, $safeUrlNameToAtt[$markdownElement['name']]); 1633 | } 1634 | 1635 | if (!empty($markdownElement['attributes'])) { 1636 | 1637 | foreach ($markdownElement['attributes'] as $att => $val) { 1638 | # filter out badly parsed attribute 1639 | if (!preg_match($goodAttribute, $att)) { 1640 | unset($markdownElement['attributes'][$att]); 1641 | } 1642 | # dump onevent attribute 1643 | elseif (self::striAtStart($att, 'on')) { 1644 | unset($markdownElement['attributes'][$att]); 1645 | } 1646 | } 1647 | } 1648 | 1649 | return $markdownElement; 1650 | } 1651 | 1652 | protected function filterUnsafeUrlInAttribute(array $markdownElement, $attribute): array 1653 | { 1654 | foreach ($this->safeLinksWhitelist as $scheme) { 1655 | 1656 | if (self::striAtStart($markdownElement['attributes'][$attribute], $scheme)) { 1657 | return $markdownElement; 1658 | } 1659 | } 1660 | 1661 | $markdownElement['attributes'][$attribute] = str_replace(':', '%3A', $markdownElement['attributes'][$attribute]); 1662 | 1663 | return $markdownElement; 1664 | } 1665 | 1666 | protected function textElements($text): array 1667 | { 1668 | # make sure no definitions are set 1669 | $this->DefinitionData = []; 1670 | 1671 | # standardize line breaks 1672 | $text = str_replace(array("\r\n", "\r"), "\n", $text); 1673 | 1674 | # remove surrounding line breaks 1675 | $text = trim($text, "\n"); 1676 | 1677 | # split text into lines 1678 | $lines = explode("\n", $text); 1679 | 1680 | # iterate through lines to identify blocks 1681 | return $this->linesElements($lines); 1682 | } 1683 | 1684 | protected $unmarkedBlockTypes; 1685 | protected $BlockTypes; 1686 | 1687 | protected function linesElements(array $lines): array 1688 | { 1689 | $Elements = []; 1690 | $CurrentBlock = null; 1691 | 1692 | foreach ($lines as $line) { 1693 | if (chop($line) === '') { 1694 | if (isset($CurrentBlock)) { 1695 | $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted']) 1696 | ? $CurrentBlock['interrupted'] + 1 1697 | : 1 1698 | ); 1699 | } 1700 | continue; 1701 | } 1702 | 1703 | while (($beforeTab = strstr($line, "\t", true)) !== false) { 1704 | $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; 1705 | 1706 | $line = $beforeTab 1707 | . str_repeat(' ', $shortage) 1708 | . substr($line, strlen($beforeTab) + 1) 1709 | ; 1710 | } 1711 | 1712 | $indent = strspn($line, ' '); 1713 | $text = $indent > 0 ? substr($line, $indent) : $line; 1714 | $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); 1715 | 1716 | if (isset($CurrentBlock['continuable'])) { 1717 | $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; 1718 | $Block = $this->$methodName($Line, $CurrentBlock); 1719 | 1720 | if (isset($Block)) { 1721 | $CurrentBlock = $Block; 1722 | 1723 | continue; 1724 | } else { 1725 | $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; 1726 | $CurrentBlock = $this->$methodName($CurrentBlock); 1727 | } 1728 | } 1729 | 1730 | $marker = $text[0]; 1731 | $blockTypes = $this->unmarkedBlockTypes; 1732 | 1733 | if (isset($this->BlockTypes[$marker])) { 1734 | foreach ($this->BlockTypes[$marker] as $blockType) { 1735 | $blockTypes[] = $blockType; 1736 | } 1737 | } 1738 | 1739 | foreach ($blockTypes as $blockType) { 1740 | $Block = $this->{"block$blockType"}($Line, $CurrentBlock); 1741 | 1742 | if (isset($Block)) { 1743 | $Block['type'] = $blockType; 1744 | 1745 | if (!isset($Block['identified'])) { 1746 | if (isset($CurrentBlock)) { 1747 | $Elements[] = $this->extractElement($CurrentBlock); 1748 | } 1749 | 1750 | $Block['identified'] = true; 1751 | } 1752 | 1753 | if ($this->isBlockContinuable($blockType)) { 1754 | $Block['continuable'] = true; 1755 | } 1756 | 1757 | $CurrentBlock = $Block; 1758 | 1759 | continue 2; 1760 | } 1761 | } 1762 | 1763 | if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') { 1764 | $Block = $this->paragraphContinue($Line, $CurrentBlock); 1765 | } 1766 | 1767 | if (isset($Block)) { 1768 | $CurrentBlock = $Block; 1769 | } else { 1770 | if (isset($CurrentBlock)) { 1771 | $Elements[] = $this->extractElement($CurrentBlock); 1772 | } 1773 | 1774 | $CurrentBlock = $this->paragraph($Line); 1775 | 1776 | $CurrentBlock['identified'] = true; 1777 | } 1778 | } 1779 | 1780 | if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) { 1781 | $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; 1782 | $CurrentBlock = $this->$methodName($CurrentBlock); 1783 | } 1784 | 1785 | if (isset($CurrentBlock)) { 1786 | $Elements[] = $this->extractElement($CurrentBlock); 1787 | } 1788 | 1789 | return $Elements; 1790 | } 1791 | 1792 | protected function isBlockContinuable($Type): bool 1793 | { 1794 | return method_exists($this, "block{$Type}Continue"); 1795 | } 1796 | 1797 | protected function isBlockCompletable($Type): bool 1798 | { 1799 | return method_exists($this, "block{$Type}Complete"); 1800 | } 1801 | 1802 | protected function extractElement(array $Component) 1803 | { 1804 | if ( ! isset($Component['element'])) { 1805 | if (isset($Component['markup'])) { 1806 | $Component['element'] = ['rawHtml' => $Component['markup']]; 1807 | } elseif (isset($Component['hidden'])) { 1808 | $Component['element'] = []; 1809 | } 1810 | } 1811 | 1812 | return $Component['element']; 1813 | } 1814 | 1815 | protected function paragraphContinue($Line, array $Block): array 1816 | { 1817 | if (isset($Block['interrupted'])) return []; 1818 | 1819 | $Block['element']['handler']['argument'] .= "\n".$Line['text']; 1820 | 1821 | return $Block; 1822 | } 1823 | 1824 | protected static function escape($text, $allowQuotes = false): string 1825 | { 1826 | return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); 1827 | } 1828 | 1829 | protected static function striAtStart($string, $needle) 1830 | { 1831 | $len = strlen($needle); 1832 | 1833 | if ($len > strlen($string)) { 1834 | return false; 1835 | } else { 1836 | return strtolower(substr($string, 0, $len)) === strtolower($needle); 1837 | } 1838 | } 1839 | 1840 | protected static function instance($name = 'default') 1841 | { 1842 | if (isset(self::$instances[$name])) { 1843 | return self::$instances[$name]; 1844 | } 1845 | 1846 | $instance = new static(); 1847 | self::$instances[$name] = $instance; 1848 | 1849 | return $instance; 1850 | } 1851 | } -------------------------------------------------------------------------------- /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 | ->setContent(content: ' # hello ') 37 | ->toHtml(); //

hello 1

38 | 39 | $this->assertSame(expected: '

hello

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

hello 2

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

hello 3

72 | 73 | $this->assertIsBool(actual: $markdown); 74 | 75 | $this->assertTrue(condition: $markdown); 76 | } 77 | 78 | 79 | /** 80 | * Test Markdown Compilation 81 | * 82 | * @return void 83 | */ 84 | public function testMarkdownCompilation(): void 85 | { 86 | $markdown = Markdown::new() 87 | ->setContent('# Title') 88 | ->setContent('# Sub-Title') 89 | ->setInlineContent('_first word with_') 90 | ->setInlineContent('[A LINK](https://github.com/fastvolt)') 91 | ->toHtml(); 92 | 93 | $this->assertSame('

Title

94 | 95 |

Sub-Title

96 | 97 | first word with 98 | 99 | A LINK', $markdown); 100 | } 101 | 102 | /** 103 | * Test Markdown Compilation 104 | * 105 | * @return void 106 | */ 107 | public function testMarkdownAdvancedCompilation(): void 108 | { 109 | $markdown = Markdown::new(sanitize: true) 110 | ->setFile('./files/heading.md') 111 | ->setInlineContent('_My name is **vincent**, the co-author of this blog_') 112 | ->setContent('Kindly follow me on my github page via: [@vincent](https://github.com/oladoyinbov).') 113 | ->setContent('Here are the lists of my projects:') 114 | ->setContent(' 115 | - Dragon CMS 116 | - Fastvolt Framework. 117 | + Fastvolt Router 118 | + Markdown Parser. 119 | ') 120 | ->setFile('./files/footer.md'); 121 | 122 | 123 | // set compilation directory 124 | $markdown->setCompileDir('./pages/'); 125 | 126 | // set second compilation directory (OPTIONAL) 127 | $markdown->setCompileDir('./pages/backup/'); 128 | 129 | // Compile The Markdown with File Name 'homepage' 130 | $result = $markdown->toHtmlFile(file_name: 'homepage'); 131 | 132 | $this->assertIsBool($result); 133 | 134 | $this->assertTrue($result === true); 135 | } 136 | } -------------------------------------------------------------------------------- /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 | ->setContent('*hello world*') 76 | ->toHtml(); // hello 1 77 | 78 | $this->assertSame('hello world', $markdown); 79 | } 80 | 81 | /** 82 | * Test Inline Markdown Compilation 83 | * 84 | * @return void 85 | */ 86 | public function testInlineMarkdownCompilation(): void 87 | { 88 | $markdown = Markdown::new() 89 | ->setInlineContent('**hello world with _emphasis_**') 90 | ->toHtml(); 91 | 92 | $this->assertSame('hello world with emphasis', $markdown); 93 | } 94 | 95 | /** 96 | * Test Multi-lined Markdown Compilation 97 | * 98 | * @return void 99 | */ 100 | public function testMultiLinedMarkdownCompilation(): void 101 | { 102 | $markdown = Markdown::new() 103 | ->setContent('# First Heading') 104 | ->setContent('# Second Heading') 105 | ->toHtml(); 106 | 107 | $this->assertSame('

First Heading

108 | 109 |

Second Heading

', $markdown); 110 | } 111 | 112 | /** 113 | * Test Markdown Compilation With Sanitization Off 114 | * 115 | * @return void 116 | */ 117 | public function testMarkdownSanitization(): void 118 | { 119 | $markdown1 = Markdown::new(sanitize: false) 120 | ->setInlineContent('

first word

') 121 | ->setInlineContent('

second word

'); 122 | 123 | $markdown2 = Markdown::new(sanitize: true) 124 | ->setInlineContent('

first word

') 125 | ->setInlineContent('

second word

'); 126 | 127 | $this->assertSame('

first word

128 | 129 |

second word

', $markdown1); 130 | 131 | $this->assertSame('<p>first word</p> 132 | 133 | <p>second word</p>', $markdown2); 134 | } 135 | } -------------------------------------------------------------------------------- /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

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