├── .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 |
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 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
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 |
103 | - List 1
104 | - List 2
105 |
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 |
234 | - Dragon CMS
235 | - Fastvolt Framework.
236 |
237 | - Fastvolt Router
238 | - Markdown Parser.
239 |
240 |
241 |
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` | `- 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 | | `` | Image | `` | `
` |
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('/^\[(.+?)\]:[ ]*(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $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 .= '' . $markdownElement['name'] . '>';
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 |
7 | - Dragon CMS
8 | - Fastvolt Framework.
9 |
10 | - Fastvolt Router
11 | - Markdown Parser.
12 |
13 |
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 |
7 | - Dragon CMS
8 | - Fastvolt Framework.
9 |
10 | - Fastvolt Router
11 | - Markdown Parser.
12 |
13 |
14 |
Thanks for Visiting My BlogPage
--------------------------------------------------------------------------------