diff --git a/phpbench.json b/phpbench.json index d62e210..4e17cab 100644 --- a/phpbench.json +++ b/phpbench.json @@ -2,5 +2,11 @@ "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", "runner.bootstrap": "vendor/autoload.php", "runner.path": "tests/Bench", - "runner.file_pattern": "*Bench.php" + "runner.file_pattern": "*Bench.php", + "runner.php_config": { + "opcache.jit_hot_loop": "2", + "opcache.jit_hot_func": "2", + "opcache.jit_hot_return": "2", + "opcache.jit_hot_side_exit": "2" + } } diff --git a/src/Lexer.php b/src/Lexer.php deleted file mode 100644 index 3573071..0000000 --- a/src/Lexer.php +++ /dev/null @@ -1,163 +0,0 @@ -rules = $rules ?? [ - new NewLineRule(), - new FrontMatterRule(), - new HeadingRule(), - new QuoteRule(), - new PreRule(), - new DivRule(), - new ThinRulerRule(), - new ThickRulerRule(), - new TableRule(), - new HtmlRule(), - new ParagraphRule(), - ]; - } - - public function withRules(Rule ...$rules): self - { - return clone($this, [ - 'rules' => $rules, - ]); - } - - public function lex(string $content): TokenCollection - { - $lexer = clone $this; - - $lexer->content = $content; - $lexer->position = 0; - $lexer->current = $lexer->content[$lexer->position] ?? null; - - $tokens = []; - - /** @var \Tempest\Markdown\NeedsStopChars[] $needsStopChars */ - $needsStopChars = []; - $providedStopChars = ''; - - foreach ($this->rules as $rule) { - if ($rule instanceof ProvidesStopChar) { - $providedStopChars .= $rule->stopChar; - } - - if ($rule instanceof NeedsStopChars) { - $needsStopChars[] = $rule; - } - } - - foreach ($needsStopChars as $rule) { - $rule->stopChars .= $providedStopChars; - } - - while ($lexer->current !== null) { - foreach ($this->rules as $rule) { - if (! $rule->shouldLex($lexer)) { - continue; - } - - $token = $rule->lex($lexer); - - if ($token instanceof Token) { - $tokens[] = $token; - $lexer->lastToken = $token; - } - - continue 2; - } - - $lexer->consume(); - } - - return new TokenCollection($tokens); - } - - public function comesNext(string $search, ?int $length = null): bool - { - $length ??= strlen($search); - - if ($length === 1) { - return ($this->content[$this->position] ?? null) === $search; - } - - return substr_compare($this->content, $search, $this->position, $length) === 0; - } - - public function consume(int $length = 1): string - { - if ($length === 0) { - return ''; - } - - if ($length === 1) { - $char = $this->content[$this->position++] ?? null; - $this->current = $this->content[$this->position] ?? null; - return $char ?? ''; - } - - $buffer = substr($this->content, $this->position, $length); - $this->position += $length; - $this->current = $this->content[$this->position] ?? null; - - return $buffer; - } - - public function consumeUntil(string $stopAt): string - { - $offset = strcspn($this->content, $stopAt, $this->position); - - return $this->consume($offset); - } - - public function consumeUntilString(string $stopAt): string - { - $pos = strpos($this->content, $stopAt, $this->position); - - if ($pos === false) { - return $this->consume(strlen($this->content) - $this->position); - } - - return $this->consume($pos - $this->position); - } - - public function consumeWhile(string $continueWhile): string - { - $offset = strspn($this->content, $continueWhile, $this->position); - - return $this->consume($offset); - } - - public function consumeIncluding(string $search): string - { - return $this->consumeUntil($search) . $this->consume(strlen($search)); - } -} diff --git a/src/LexerRules/BoldRule.php b/src/LexerRules/BoldRule.php deleted file mode 100644 index 5ab0365..0000000 --- a/src/LexerRules/BoldRule.php +++ /dev/null @@ -1,28 +0,0 @@ -comesNext('*', 1); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeWhile('*'); - $buffer = $lexer->consumeUntil('*'); - $lexer->consumeWhile('*'); - - return new BoldToken($buffer); - } -} diff --git a/src/LexerRules/CodeRule.php b/src/LexerRules/CodeRule.php deleted file mode 100644 index cb95c5b..0000000 --- a/src/LexerRules/CodeRule.php +++ /dev/null @@ -1,38 +0,0 @@ -comesNext('`', 1); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeIncluding('`'); - - $language = null; - - if ($lexer->comesNext('{', 1)) { - $lexer->consume(); - $language = $lexer->consumeUntil('}'); - $lexer->consume(); - } - - $content = $lexer->consumeUntil('`'); - - $lexer->consumeIncluding('`'); - - return new CodeToken($language, $content); - } -} diff --git a/src/LexerRules/DivRule.php b/src/LexerRules/DivRule.php deleted file mode 100644 index 9a4a63b..0000000 --- a/src/LexerRules/DivRule.php +++ /dev/null @@ -1,35 +0,0 @@ -comesNext(':::', 3); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeWhile(':'); - - $class = $lexer->consumeUntil(Lexer::NEW_LINE) ?: null; - - $lexer->consumeWhile(Lexer::NEW_LINE); - - $content = $lexer->consumeUntilString(':::'); - - $lexer->consumeWhile(':'); - $lexer->consumeWhile(Lexer::NEW_LINE); - - return new DivToken( - class: $class, - content: $content, - ); - } -} diff --git a/src/LexerRules/FrontMatterRule.php b/src/LexerRules/FrontMatterRule.php deleted file mode 100644 index d84da70..0000000 --- a/src/LexerRules/FrontMatterRule.php +++ /dev/null @@ -1,44 +0,0 @@ -position !== 0) { - return false; - } - - if (! $lexer->comesNext('---', 3)) { - return false; - } - - return true; - } - - public function lex(Lexer $lexer): ?Token - { - $lexer->consumeWhile('-'); - $lexer->consumeWhile(Lexer::NEW_LINE); - $content = $lexer->consumeUntilString('---'); - $lexer->consumeWhile('-'); - $lexer->consumeWhile(Lexer::NEW_LINE); - - try { - $data = Yaml::parse($content); - } catch (Throwable) { - // TODO: proper error - return null; - } - - return new FrontMatterToken($data); - } -} diff --git a/src/LexerRules/HeadingRule.php b/src/LexerRules/HeadingRule.php deleted file mode 100644 index 36b15d9..0000000 --- a/src/LexerRules/HeadingRule.php +++ /dev/null @@ -1,25 +0,0 @@ -comesNext('#', 1); - } - - public function lex(Lexer $lexer): Token - { - $buffer = $lexer->consumeUntil(Lexer::NEW_LINE); - - $level = strspn($buffer, '#'); - - return new HeadingToken(substr($buffer, $level) |> trim(...), $level); - } -} diff --git a/src/LexerRules/HtmlRule.php b/src/LexerRules/HtmlRule.php deleted file mode 100644 index 5e7e2d8..0000000 --- a/src/LexerRules/HtmlRule.php +++ /dev/null @@ -1,59 +0,0 @@ -comesNext('<'); - } - - public function lex(Lexer $lexer): Token - { - $openingTag = $lexer->consumeIncluding('>'); - - // Self-closing tags (,
) need no closing tag. - if (str_ends_with($openingTag, '/>')) { - return new HtmlToken($openingTag . $lexer->consumeWhile(Lexer::NEW_LINE)); - } - - // Extract tag name from opening tag: "
", 1)); - - $content = $openingTag; - $depth = 1; - - // Track nesting depth for the same tag name so that e.g. - //
is consumed as a single token. - while ($depth > 0 && $lexer->current !== null) { - // Bulk-skip to '<' on each iteration so comesNext is only called - // at actual tag boundaries, not character-by-character. - $content .= $lexer->consumeUntil('<'); - - if ($lexer->current === null) { - break; - } - - if ($lexer->comesNext("consumeIncluding('>'); - } elseif ($lexer->comesNext("<{$tagName}")) { - $depth++; - $content .= $lexer->consumeIncluding('>'); - } else { - // Some other tag — consume the '<' and continue. - $content .= $lexer->consume(); - } - } - - $content .= $lexer->consumeWhile(Lexer::NEW_LINE); - - return new HtmlToken($content); - } -} diff --git a/src/LexerRules/ImageRule.php b/src/LexerRules/ImageRule.php deleted file mode 100644 index 162914b..0000000 --- a/src/LexerRules/ImageRule.php +++ /dev/null @@ -1,36 +0,0 @@ -comesNext('![', 2); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeIncluding('!['); - $alt = $lexer->consumeUntil(']') ?: null; - $lexer->consumeIncluding(']'); - - if (! $lexer->comesNext('(', 1)) { - // TODO: throw error - } - - $lexer->consumeIncluding('('); - $href = $lexer->consumeUntil(')'); - $lexer->consumeIncluding(')'); - - return new ImageToken($href, $alt); - } -} diff --git a/src/LexerRules/ItalicRule.php b/src/LexerRules/ItalicRule.php deleted file mode 100644 index 443b43a..0000000 --- a/src/LexerRules/ItalicRule.php +++ /dev/null @@ -1,28 +0,0 @@ -comesNext('_', 1); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeWhile('_'); - $buffer = $lexer->consumeUntil('_'); - $lexer->consumeWhile('_'); - - return new ItalicToken(trim($buffer, '_')); - } -} diff --git a/src/LexerRules/LinkRule.php b/src/LexerRules/LinkRule.php deleted file mode 100644 index 422adf6..0000000 --- a/src/LexerRules/LinkRule.php +++ /dev/null @@ -1,36 +0,0 @@ -comesNext('[', 1); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeIncluding('['); - $content = $lexer->consumeUntil(']'); - $lexer->consumeIncluding(']'); - - $href = null; - - if ($lexer->comesNext('(', 1)) { - $lexer->consumeIncluding('('); - $href = $lexer->consumeUntil(')'); - $lexer->consumeIncluding(')'); - } - - return new LinkToken($content, $href); - } -} diff --git a/src/LexerRules/ListRule.php b/src/LexerRules/ListRule.php deleted file mode 100644 index ddb973b..0000000 --- a/src/LexerRules/ListRule.php +++ /dev/null @@ -1,45 +0,0 @@ -comesNext('-', 1); - } - - public function lex(Lexer $lexer): ?Token - { - $lexer->consumeIncluding('-'); - $content = trim($lexer->consumeUntil(Lexer::NEW_LINE)); - $lexer->consumeWhile(Lexer::NEW_LINE); - - $childContent = ''; - - while ($lexer->comesNext(' ', 2)) { - $lexer->consume(2); // strip one indent level - $childContent .= $lexer->consumeUntil(Lexer::NEW_LINE) . PHP_EOL; - $lexer->consumeWhile(Lexer::NEW_LINE); - } - - $children = $childContent !== '' - ? new Lexer([new ListRule()])->lex($childContent)[0] - : null; - - $item = new ListItem($content, $children); - - if ($lexer->lastToken instanceof ListToken) { - $lexer->lastToken->items[] = $item; - return null; - } - - return new ListToken([$item]); - } -} diff --git a/src/LexerRules/NewLineRule.php b/src/LexerRules/NewLineRule.php deleted file mode 100644 index ad31235..0000000 --- a/src/LexerRules/NewLineRule.php +++ /dev/null @@ -1,23 +0,0 @@ -current === "\n" || $lexer->current === "\r"; - } - - public function lex(Lexer $lexer): Token - { - $buffer = $lexer->consumeWhile(Lexer::NEW_LINE); - - return new NewLineToken($buffer); - } -} diff --git a/src/LexerRules/OrderedListRule.php b/src/LexerRules/OrderedListRule.php deleted file mode 100644 index b03bc9f..0000000 --- a/src/LexerRules/OrderedListRule.php +++ /dev/null @@ -1,46 +0,0 @@ -current ?? ''); - } - - public function lex(Lexer $lexer): ?Token - { - $lexer->consumeWhile('0123456789'); - $lexer->consumeIncluding('.'); - $content = trim($lexer->consumeUntil(Lexer::NEW_LINE)); - $lexer->consumeWhile(Lexer::NEW_LINE); - - $childContent = ''; - - while ($lexer->comesNext(' ', 2)) { - $lexer->consume(2); // strip one indent level - $childContent .= $lexer->consumeUntil(Lexer::NEW_LINE) . "\n"; - $lexer->consumeWhile(Lexer::NEW_LINE); - } - - $children = $childContent !== '' - ? new Lexer([new OrderedListRule()])->lex($childContent)[0] - : null; - - $item = new ListItem($content, $children); - - if ($lexer->lastToken instanceof OrderedListToken) { - $lexer->lastToken->items[] = $item; - return null; - } - - return new OrderedListToken([$item]); - } -} diff --git a/src/LexerRules/ParagraphRule.php b/src/LexerRules/ParagraphRule.php deleted file mode 100644 index e809908..0000000 --- a/src/LexerRules/ParagraphRule.php +++ /dev/null @@ -1,23 +0,0 @@ -consumeUntil(Lexer::NEW_LINE) . $lexer->consumeWhile(Lexer::NEW_LINE); - - return new ParagraphToken($content); - } -} diff --git a/src/LexerRules/PreRule.php b/src/LexerRules/PreRule.php deleted file mode 100644 index e6e971a..0000000 --- a/src/LexerRules/PreRule.php +++ /dev/null @@ -1,35 +0,0 @@ -comesNext('```', 3); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeIncluding('```'); - - $language = $lexer->consumeUntil(Lexer::NEW_LINE); - - $lexer->consumeWhile(Lexer::NEW_LINE); - - $content = $lexer->consumeUntilString('```'); - - $lexer->consumeIncluding('```'); - $lexer->consumeWhile(Lexer::NEW_LINE); - - return new PreToken( - language: $language ?: null, - content: trim($content), - ); - } -} diff --git a/src/LexerRules/QuoteRule.php b/src/LexerRules/QuoteRule.php deleted file mode 100644 index 37b1be4..0000000 --- a/src/LexerRules/QuoteRule.php +++ /dev/null @@ -1,42 +0,0 @@ -'; - - public function shouldLex(Lexer $lexer): bool - { - return $lexer->comesNext('>', 1); - } - - public function lex(Lexer $lexer): Token - { - $lines = []; - - while ($lexer->comesNext('>', 1)) { - $line = $lexer->consumeUntil(Lexer::NEW_LINE); - - if (str_starts_with($line, '> ')) { - $line = substr($line, 2); - } else { - $line = substr($line, 1); - } - - $lines[] = $line; - - $lexer->consumeWhile(Lexer::NEW_LINE); - } - - $content = implode(PHP_EOL, $lines); - - return new QuoteToken($content); - } -} diff --git a/src/LexerRules/StrikethroughRule.php b/src/LexerRules/StrikethroughRule.php deleted file mode 100644 index 078aa8c..0000000 --- a/src/LexerRules/StrikethroughRule.php +++ /dev/null @@ -1,28 +0,0 @@ -comesNext('~', 1); - } - - public function lex(Lexer $lexer): Token - { - $lexer->consumeWhile('~'); - $buffer = $lexer->consumeUntil('~'); - $lexer->consumeWhile('~'); - - return new StrikethroughToken($buffer); - } -} diff --git a/src/LexerRules/TableRule.php b/src/LexerRules/TableRule.php deleted file mode 100644 index 22d63eb..0000000 --- a/src/LexerRules/TableRule.php +++ /dev/null @@ -1,48 +0,0 @@ -comesNext('|', 1); - } - - public function lex(Lexer $lexer): ?Token - { - $line = $lexer->consumeUntil(Lexer::NEW_LINE); - $lexer->consumeWhile(Lexer::NEW_LINE); - - $cells = $line - |> (fn ($x) => trim($x, '| ')) - |> (fn ($x) => explode('|', $x)) - |> (fn ($x) => array_map(trim(...), $x)) - |> (fn ($x) => array_filter($x, fn (string $cell) => $cell !== '')) - |> array_values(...); - - // Filter out separator rows - $isSeparator = array_filter($cells, fn (string $cell) => trim($cell, '-: ') !== '') === []; - - if ($isSeparator) { - return null; - } - - $isHeader = ! $lexer->lastToken instanceof TableToken; - - $row = new TableRow($cells, $isHeader); - - if ($lexer->lastToken instanceof TableToken) { - $lexer->lastToken->rows[] = $row; - return null; - } - - return new TableToken([$row]); - } -} diff --git a/src/LexerRules/TextRule.php b/src/LexerRules/TextRule.php deleted file mode 100644 index 0a555aa..0000000 --- a/src/LexerRules/TextRule.php +++ /dev/null @@ -1,39 +0,0 @@ -stopChars !== '' - ? $lexer->consumeUntil($this->stopChars) - : ''; - - if ($text === '') { - $text = $lexer->consume(); - } - - if ($lexer->lastToken instanceof TextToken) { - $lexer->lastToken->append($text); - return null; - } - - return new TextToken($text); - } -} diff --git a/src/LexerRules/ThickRulerRule.php b/src/LexerRules/ThickRulerRule.php deleted file mode 100644 index e282b98..0000000 --- a/src/LexerRules/ThickRulerRule.php +++ /dev/null @@ -1,27 +0,0 @@ -comesNext('===', 3); - } - - public function lex(Lexer $lexer): Token - { - $content = $lexer->consumeWhile('='); - - return new RulerToken( - $content, - RulerType::THICK, - ); - } -} diff --git a/src/LexerRules/ThinRulerRule.php b/src/LexerRules/ThinRulerRule.php deleted file mode 100644 index 91c5e69..0000000 --- a/src/LexerRules/ThinRulerRule.php +++ /dev/null @@ -1,27 +0,0 @@ -comesNext('---', 3); - } - - public function lex(Lexer $lexer): Token - { - $content = $lexer->consumeWhile('-'); - - return new RulerToken( - $content, - RulerType::THIN, - ); - } -} diff --git a/src/Markdown.php b/src/Markdown.php index dadba68..110d59b 100644 --- a/src/Markdown.php +++ b/src/Markdown.php @@ -1,5 +1,7 @@ highlighter, - ); - - return $parser->parse($content); + return parse_markdown($content, $this->highlighter); } } diff --git a/src/NeedsStopChars.php b/src/NeedsStopChars.php deleted file mode 100644 index 97544e3..0000000 --- a/src/NeedsStopChars.php +++ /dev/null @@ -1,8 +0,0 @@ - $this->lexer->withRules(...$rules), - ]); + return parse_markdown($input, $this->highlighter); } +} - public function parse(string $input): ParsedMarkdown - { - $tokens = $this->lexer->lex($input); +// active highlighter. set by parse_markdown, read by emit_code_tag. avoids threading +// the dependency through every signature. +final class ParserState +{ + public static ?Highlighter $highlighter = null; +} + +// emit_inline context ids. DIV/TABLE alias QUOTE/PARAGRAPH for call-site clarity. +final class InlineContext +{ + public const int PARAGRAPH = 1; + public const int BOLD = 2; + public const int ITALIC = 3; + public const int LINK = 4; + public const int STRIKE = 5; + public const int QUOTE = 6; + public const int DIV = self::QUOTE; // divs nest blockquotes, so same inline rules as QUOTE. + public const int TABLE = self::PARAGRAPH; // table cells take the same inline rules as paragraphs. + + public const int DEPTH_MAX = 64; + + // invariant: every char here has a matching arm in emit_inline. + public const array STOP_CHARS = [ + self::PARAGRAPH => '*_[!`', + self::BOLD => '_[~', + self::ITALIC => '*[~', + self::LINK => '*_~', + self::STRIKE => '*_[', + self::QUOTE => '>*_[!', + ]; +} - $html = ''; +// scan_blocks dispatches on the first char of each block to a consume_X, which +// either emits html directly or hands a slice to emit_inline. all consume_X / +// emit_X take (source, position, ..., string &$out) and return the offset past +// what they consumed; output goes straight into $out, recursion never copies. +function parse_markdown(string $source, ?Highlighter $highlighter): ParsedMarkdown +{ + ParserState::$highlighter = $highlighter; + $front_matter = []; + $html = ''; + scan_blocks($source, $html, $front_matter); + + return new ParsedMarkdown($html, $front_matter); +} - $frontMatter = []; +// block scanner. +// add a block rule: match arm below + consume_X helper returning the new position. +function scan_blocks( + string $source, + string &$out, + array &$front_matter, +): void { + $length_bytes = \strlen($source); + $position = 0; + // rows of the currently-open table, if any: [[cells, is_header], ...]. + $table_rows = []; - foreach ($tokens as $token) { - $html .= $token->parse($this); + while ($position < $length_bytes) { + $char = $source[$position]; - if ($token instanceof FrontMatterToken) { - $frontMatter = [...$frontMatter, ...$token->data]; + if ($char === "\n" || $char === "\r") { + if ($table_rows !== []) { + render_table($table_rows, $out); + $table_rows = []; } + $newline_end = $position + \strspn($source, "\r\n", $position); + $out .= \substr($source, $position, $newline_end - $position); + $position = $newline_end; + continue; + } + + if ($char !== '|' && $table_rows !== []) { + render_table($table_rows, $out); + $table_rows = []; + } + + $position = match (true) { + // front matter only at byte 0; ruler --- elsewhere falls into the ruler arm. + $position === 0 && $char === '-' && \substr_compare($source, '---', 0, 3) === 0 + => consume_front_matter($source, $position, $length_bytes, $front_matter), + $char === '#' + => consume_heading($source, $position, $out), + $char === '>' + => emit_blockquote($source, $position, $length_bytes, 0, $out), + $char === '`' && \substr_compare($source, '```', $position, 3) === 0 + => consume_pre($source, $position, $length_bytes, $out), + $char === ':' && \substr_compare($source, ':::', $position, 3) === 0 + => consume_div($source, $position, $length_bytes, $out), + ($char === '=' || $char === '-') && \substr_compare($source, "$char$char$char", $position, 3) === 0 + => consume_ruler($source, $position, $char, $out), + $char === '-' && list_marker_length($source, $position, $length_bytes, false) > 0 + => consume_list($source, $position, $length_bytes, false, $out), + \ctype_digit($char) && list_marker_length($source, $position, $length_bytes, true) > 0 + => consume_list($source, $position, $length_bytes, true, $out), + $char === '|' + => consume_table_row($source, $position, $table_rows), + $char === '<' + => consume_html($source, $position, $length_bytes, $out), + default + => consume_paragraph($source, $position, $out), + }; + } + + if ($table_rows !== []) { + render_table($table_rows, $out); + } +} + +// +// block consumers. +// + +function consume_ruler(string $source, int $position, string $char, string &$out): int +{ + $out .= '
'; + return $position + \strspn($source, $char, $position); +} + +function consume_heading(string $source, int $position, string &$out): int +{ + $position_end = $position + \strcspn($source, "\r\n", $position); + $line = \substr($source, $position, $position_end - $position); + $level = \strspn($line, '#'); + $content = \trim(\substr($line, $level)); + $slug = \str_replace(' ', '-', \strtolower($content)); + $out .= "{$content}"; + return $position_end; +} + +function consume_paragraph( + string $source, + int $position, + string &$out, +): int { + // trailing newline run is part of the slice (matches OO ParagraphRule). + $line_end = $position + \strcspn($source, "\r\n", $position); + $paragraph_end = $line_end + \strspn($source, "\r\n", $line_end); + $out .= '

'; + emit_inline($source, $position, $paragraph_end, InlineContext::PARAGRAPH, 0, $out); + $out .= '

'; + return $paragraph_end; +} + +// `- ` / `\d+. ` markers (trailing space required). children are 2-space-indented +// lines, recursively rendered as the same kind (mixing kinds silently drops). +function consume_list( + string $source, + int $position, + int $length_bytes, + bool $ordered, + string &$out, +): int { + $tag = $ordered ? 'ol' : 'ul'; + $out .= "<{$tag}>"; + + while (($marker_length = list_marker_length($source, $position, $length_bytes, $ordered)) > 0) { + $position += $marker_length; + + $line_end = $position + \strcspn($source, "\r\n", $position); + $content = \trim(\substr($source, $position, $line_end - $position)); + $position = $line_end + \strspn($source, "\r\n", $line_end); + + // 2-space-indented child lines, prefix stripped; deeper indents are kept + // so further nesting de-indents correctly on recursion. + $child_content = ''; + while ( + $position + 1 < $length_bytes + && $source[$position] === ' ' + && $source[$position + 1] === ' ' + ) { + $position += 2; + $child_line_end = $position + \strcspn($source, "\r\n", $position); + $child_content .= \substr($source, $position, $child_line_end - $position) . "\n"; + $position = $child_line_end + \strspn($source, "\r\n", $child_line_end); + } + + $out .= '
  • '; + emit_inline($content, 0, \strlen($content), InlineContext::PARAGRAPH, 0, $out); + + if ( + $child_content !== '' + && list_marker_length($child_content, 0, \strlen($child_content), $ordered) > 0 + ) { + consume_list($child_content, 0, \strlen($child_content), $ordered, $out); + } + + $out .= '
  • '; + } + + $out .= ""; + return $position; +} + +// byte length of a list marker, 0 if none. trailing space required. +function list_marker_length( + string $source, + int $position, + int $length_bytes, + bool $ordered, +): int { + if ($position >= $length_bytes) { + return 0; + } + + if (! $ordered) { + if ($source[$position] !== '-') { + return 0; } + if ($position + 1 >= $length_bytes || $source[$position + 1] !== ' ') { + return 0; + } + return 2; + } + + $digit_run = \strspn($source, '0123456789', $position); + if ($digit_run === 0) { + return 0; + } + $after_digits = $position + $digit_run; + if ($after_digits + 1 >= $length_bytes) { + return 0; + } + if ($source[$after_digits] !== '.' || $source[$after_digits + 1] !== ' ') { + return 0; + } + return $digit_run + 2; +} + +function consume_table_row(string $source, int $position, array &$table_rows): int +{ + $position_end = $position + \strcspn($source, "\r\n", $position); + $line = \substr($source, $position, $position_end - $position); + $cells = parse_table_row($line); + if ($cells !== null) { + // first non-separator row is the header (matches TableRule). + $is_header = $table_rows === []; + $table_rows[] = [$cells, $is_header]; + } + return $position_end + \strspn($source, "\r\n", $position_end); +} + +function consume_front_matter( + string $source, + int $position, + int $length_bytes, + array &$front_matter, +): int { + $position += \strspn($source, '-', $position); + $position += \strspn($source, "\r\n", $position); + $close = \strpos($source, '---', $position); - return new ParsedMarkdown($html, $frontMatter); + if ($close === false) { + $content = \substr($source, $position); + $position = $length_bytes; + } else { + $content = \substr($source, $position, $close - $position); + $position = $close; } + + $position += \strspn($source, '-', $position); + $position += \strspn($source, "\r\n", $position); + + try { + $data = Yaml::parse($content); + } catch (Throwable) { + $data = null; + } + + if (\is_array($data)) { + $front_matter = [...$front_matter, ...$data]; + } + + return $position; +} + +// collect `>`-prefixed lines, strip prefix, inline-render in QUOTE context. +function emit_blockquote( + string $source, + int $position, + int $end, + int $depth, + string &$out, +): int { + $content = ''; + while ($position < $end && $source[$position] === '>') { + $line_end = $position + \strcspn($source, "\r\n", $position, $end - $position); + $line = \substr($source, $position, $line_end - $position); + if ($content !== '') { + $content .= PHP_EOL; + } + $content .= \str_starts_with($line, '> ') ? \substr($line, 2) : \substr($line, 1); + $position = $line_end + \strspn($source, "\r\n", $line_end, $end - $line_end); + } + + $out .= '
    '; + emit_inline($content, 0, \strlen($content), InlineContext::QUOTE, $depth + 1, $out); + $out .= '
    '; + + return $position; +} + +function consume_pre( + string $source, + int $position, + int $length_bytes, + string &$out, +): int { + $position += 3; + $line_end = $position + \strcspn($source, "\r\n", $position); + $language = \substr($source, $position, $line_end - $position); + $position = $line_end + \strspn($source, "\r\n", $line_end); + + $close = \strpos($source, '```', $position); + if ($close === false) { + $content = \substr($source, $position); + $position = $length_bytes; + } else { + $content = \substr($source, $position, $close - $position); + $position = $close + 3; + } + + $position += \strspn($source, "\r\n", $position); + $content = \trim($content); + $language = $language !== '' ? $language : null; + + $out .= '
    ';
    +    emit_code_tag($content, $language, $out);
    +    $out .= '
    '; + + return $position; +} + +function consume_div( + string $source, + int $position, + int $length_bytes, + string &$out, +): int { + $position += \strspn($source, ':', $position); + $line_end = $position + \strcspn($source, "\r\n", $position); + $class = \substr($source, $position, $line_end - $position); + $class = $class !== '' ? $class : null; + $position = $line_end + \strspn($source, "\r\n", $line_end); + + $close = \strpos($source, ':::', $position); + if ($close === false) { + $content_start = $position; + $content_end = $length_bytes; + $position = $length_bytes; + } else { + $content_start = $position; + $content_end = $close; + $position = $close; + } + + $position += \strspn($source, ':', $position); + $position += \strspn($source, "\r\n", $position); + + $class_attr = $class !== null ? " class=\"{$class}\"" : ''; + $out .= ""; + emit_inline($source, $content_start, $content_end, InlineContext::DIV, 0, $out); + $out .= '
    '; + + return $position; +} + +function consume_html(string $source, int $position, int $length_bytes, string &$out): int +{ + $opening = ''; + $position = consume_tag($source, $position, $length_bytes, $opening); + + // no `>` in the rest of input: opening is the trailing bytes, emit and done. + if (! \str_ends_with($opening, '>')) { + $out .= $opening; + return $position; + } + + if (\str_ends_with($opening, '/>')) { + $newline_end = $position + \strspn($source, "\r\n", $position); + $out .= $opening . \substr($source, $position, $newline_end - $position); + return $newline_end; + } + + // leading word of the opening tag: "
    "div". + $tag_name_length = \strcspn($opening, " \t\n\r/>", 1); + $tag_name = \substr($opening, 1, $tag_name_length); + $open_match = "<{$tag_name}"; + $close_match = " 0 && $position < $length_bytes) { + $next_tag_open = \strpos($source, '<', $position); + if ($next_tag_open === false) { + $out .= \substr($source, $position); + $position = $length_bytes; + break; + } + $out .= \substr($source, $position, $next_tag_open - $position); + $position = $next_tag_open; + + if (\substr_compare($source, $close_match, $position, $close_length) === 0) { + $depth--; + $position = consume_tag($source, $position, $length_bytes, $tag); + $out .= $tag; + } elseif (\substr_compare($source, $open_match, $position, $open_length) === 0) { + $depth++; + $position = consume_tag($source, $position, $length_bytes, $tag); + $out .= $tag; + } else { + // unrelated `<`, consume and keep scanning. + $out .= '<'; + $position++; + } + } + + $newline_end = $position + \strspn($source, "\r\n", $position); + $out .= \substr($source, $position, $newline_end - $position); + + return $newline_end; +} + +// consume up to and including the next `>`; caller checks $tag_out ends in `>`. +function consume_tag(string $source, int $position, int $length_bytes, string &$tag_out): int +{ + $tag_close = \strpos($source, '>', $position); + if ($tag_close === false) { + $tag_out = \substr($source, $position); + return $length_bytes; + } + $tag_out = \substr($source, $position, $tag_close - $position + 1); + return $tag_close + 1; +} + +// +// tables. +// + +// trimmed cells, or null for a separator row. +function parse_table_row(string $line): ?array +{ + $cells = []; + foreach (\explode('|', \trim($line, '| ')) as $cell) { + $trimmed = \trim($cell); + if ($trimmed !== '') { + $cells[] = $trimmed; + } + } + + if ($cells === []) { + return null; + } + + foreach ($cells as $cell) { + if (\trim($cell, '-: ') !== '') { + return $cells; + } + } + + return null; +} + +function render_table(array $table_rows, string &$out): void +{ + $headers = []; + $bodies = []; + foreach ($table_rows as [$cells, $is_header]) { + if ($is_header) { + $headers[] = $cells; + } else { + $bodies[] = $cells; + } + } + + $out .= ''; + + if ($headers !== []) { + $out .= ''; + render_table_rows($headers, 'th', $out); + $out .= ''; + } + + if ($bodies !== []) { + $out .= ''; + render_table_rows($bodies, 'td', $out); + $out .= ''; + } + + $out .= '
    '; +} + +function render_table_rows(array $rows, string $cell_tag, string &$out): void +{ + foreach ($rows as $cells) { + $out .= ''; + foreach ($cells as $cell) { + $out .= "<{$cell_tag}>"; + emit_inline($cell, 0, \strlen($cell), InlineContext::TABLE, 0, $out); + $out .= ""; + } + $out .= ''; + } +} + +// +// inline dispatcher and emitters. +// + +// with optional language-X class; block contexts wrap in
     at the call site.
    +function emit_code_tag(string $content, ?string $language, string &$out): void
    +{
    +    $highlighter = ParserState::$highlighter;
    +
    +    if ($language === null && $highlighter !== null) {
    +        $language = $highlighter->fallbackLanguage?->getName();
    +    }
    +
    +    if ($highlighter !== null) {
    +        $content = $highlighter->parse($content, $language);
    +    }
    +
    +    $class = $language !== null ? " class=\"language-{$language}\"" : '';
    +    $out .= "{$content}";
    +}
    +
    +// emit html for slice [$start, $end) of $content.
    +// add an inline rule: STOP_CHARS entry + match arm below + emit_X helper.
    +function emit_inline(
    +    string $content,
    +    int $start,
    +    int $end,
    +    int $context,
    +    int $depth,
    +    string &$out,
    +): void {
    +    \assert($depth < InlineContext::DEPTH_MAX);
    +    \assert($start >= 0 && $end >= $start);
    +
    +    $stop = InlineContext::STOP_CHARS[$context];
    +
    +    $position = $start;
    +
    +    while ($position < $end) {
    +        $run = \strcspn($content, $stop, $position, $end - $position);
    +        if ($run > 0) {
    +            $out .= \substr($content, $position, $run);
    +            $position += $run;
    +            if ($position >= $end) {
    +                break;
    +            }
    +        }
    +
    +        $char = $content[$position];
    +
    +        // bare `!` not followed by `[` is literal text; bail keeps the match one-arm-per-char.
    +        if ($char === '!' && ($position + 1 >= $end || $content[$position + 1] !== '[')) {
    +            $out .= '!';
    +            $position++;
    +            continue;
    +        }
    +
    +        $position = match ($char) {
    +            '*' => emit_paired($content, $position, $end, '*', 'strong', InlineContext::BOLD, $depth, $out),
    +            '_' => emit_paired($content, $position, $end, '_', 'em', InlineContext::ITALIC, $depth, $out),
    +            '~' => emit_paired($content, $position, $end, '~', 's', InlineContext::STRIKE, $depth, $out),
    +            '[' => emit_link($content, $position, $end, $depth, $out),
    +            '!' => emit_image($content, $position, $end, $out),
    +            '`' => emit_code_inline($content, $position, $end, $out),
    +            '>' => emit_blockquote($content, $position, $end, $depth, $out),
    +        };
    +    }
    +}
    +
    +// same-marker emphasis: skip leading run, capture inner, skip trailing run, recurse.
    +function emit_paired(
    +    string $content,
    +    int $position,
    +    int $end,
    +    string $marker,
    +    string $tag,
    +    int $inner_context,
    +    int $depth,
    +    string &$out,
    +): int {
    +    $position += \strspn($content, $marker, $position, $end - $position);
    +    $inner_start = $position;
    +    $position += \strcspn($content, $marker, $position, $end - $position);
    +    $inner_end = $position;
    +    $position += \strspn($content, $marker, $position, $end - $position);
    +
    +    $out .= "<{$tag}>";
    +    emit_inline($content, $inner_start, $inner_end, $inner_context, $depth + 1, $out);
    +    $out .= "";
    +
    +    return $position;
    +}
    +
    +function emit_link(
    +    string $content,
    +    int $position,
    +    int $end,
    +    int $depth,
    +    string &$out,
    +): int {
    +    $position++; // skip '['
    +    $inner_start = $position;
    +    $position += \strcspn($content, ']', $position, $end - $position);
    +    $inner_end = $position;
    +    if ($position < $end && $content[$position] === ']') {
    +        $position++;
    +    }
    +
    +    $href = null;
    +    if ($position < $end && $content[$position] === '(') {
    +        $position++;
    +        $href_run = \strcspn($content, ')', $position, $end - $position);
    +        $href = \substr($content, $position, $href_run);
    +        $position += $href_run;
    +        if ($position < $end && $content[$position] === ')') {
    +            $position++;
    +        }
    +    }
    +
    +    // `*` prefix on href opens in a new tab (matches LinkToken).
    +    $target_attr = '';
    +    if ($href !== null && \str_starts_with($href, '*')) {
    +        $href = \substr($href, 1);
    +        $target_attr = ' target="_blank" rel="noopener noreferrer"';
    +    }
    +    $href_attr = $href ?? '';
    +
    +    $out .= "";
    +    emit_inline($content, $inner_start, $inner_end, InlineContext::LINK, $depth + 1, $out);
    +    $out .= '';
    +
    +    return $position;
    +}
    +
    +function emit_image(string $content, int $position, int $end, string &$out): int
    +{
    +    $position += 2; // skip '!['
    +    $alt_run = \strcspn($content, ']', $position, $end - $position);
    +    $alt = $alt_run > 0 ? \substr($content, $position, $alt_run) : null;
    +    $position += $alt_run;
    +    if ($position < $end && $content[$position] === ']') {
    +        $position++;
    +    }
    +
    +    $href = '';
    +    if ($position < $end && $content[$position] === '(') {
    +        $position++;
    +        $href_run = \strcspn($content, ')', $position, $end - $position);
    +        $href = \substr($content, $position, $href_run);
    +        $position += $href_run;
    +        if ($position < $end && $content[$position] === ')') {
    +            $position++;
    +        }
    +    }
    +
    +    $alt_attr = $alt !== null ? " alt=\"{$alt}\"" : '';
    +    $out .= "";
    +
    +    return $position;
    +}
    +
    +function emit_code_inline(string $content, int $position, int $end, string &$out): int
    +{
    +    $position++; // skip opening '`'
    +
    +    // optional `{lang}` info string after the opening backtick:
    +    $language = null;
    +    if ($position < $end && $content[$position] === '{') {
    +        $position++;
    +        $brace_run = \strcspn($content, '}', $position, $end - $position);
    +        $language = \substr($content, $position, $brace_run);
    +        $position += $brace_run;
    +        if ($position < $end && $content[$position] === '}') {
    +            $position++;
    +        }
    +    }
    +
    +    $code_run = \strcspn($content, '`', $position, $end - $position);
    +    $code = \substr($content, $position, $code_run);
    +    $position += $code_run;
    +    if ($position < $end && $content[$position] === '`') {
    +        $position++;
    +    }
    +
    +    emit_code_tag($code, $language, $out);
    +
    +    return $position;
     }
    diff --git a/src/ProvidesStopChar.php b/src/ProvidesStopChar.php
    deleted file mode 100644
    index 4333d06..0000000
    --- a/src/ProvidesStopChar.php
    +++ /dev/null
    @@ -1,8 +0,0 @@
    -
    - * @implements ArrayAccess
    - */
    -final class TokenCollection implements IteratorAggregate, ArrayAccess
    -{
    -    public function __construct(
    -        private array $tokens = [],
    -    ) {}
    -
    -    public function add(Token $token): self
    -    {
    -        $this->tokens[] = $token;
    -
    -        return $this;
    -    }
    -
    -    public function getIterator(): Traversable
    -    {
    -        return new ArrayIterator($this->tokens);
    -    }
    -
    -    public function offsetExists(mixed $offset): bool
    -    {
    -        return isset($this->tokens[$offset]);
    -    }
    -
    -    public function offsetGet(mixed $offset): mixed
    -    {
    -        return $this->tokens[$offset] ?? null;
    -    }
    -
    -    public function offsetSet(mixed $offset, mixed $value): void
    -    {
    -        $this->tokens[$offset] = $value;
    -    }
    -
    -    public function offsetUnset(mixed $offset): void
    -    {
    -        unset($this->tokens[$offset]);
    -    }
    -}
    diff --git a/src/Tokens/BoldToken.php b/src/Tokens/BoldToken.php
    deleted file mode 100644
    index 2405911..0000000
    --- a/src/Tokens/BoldToken.php
    +++ /dev/null
    @@ -1,31 +0,0 @@
    -withRules(
    -                new ItalicRule(),
    -                new StrikethroughRule(),
    -                new LinkRule(),
    -                new TextRule(),
    -            )
    -            ->parse($this->content);
    -
    -        return "{$content}";
    -    }
    -}
    diff --git a/src/Tokens/CodeToken.php b/src/Tokens/CodeToken.php
    deleted file mode 100644
    index 7458538..0000000
    --- a/src/Tokens/CodeToken.php
    +++ /dev/null
    @@ -1,33 +0,0 @@
    -language;
    -
    -        if (! $language && $parser->highlighter) {
    -            $language = $parser->highlighter->fallbackLanguage?->getName();
    -        }
    -
    -        if ($parser->highlighter) {
    -            $content = $parser->highlighter->parse($this->content, $language);
    -        } else {
    -            $content = $this->content;
    -        }
    -
    -        $class = $language ? " class=\"language-{$language}\"" : '';
    -
    -        return "{$content}";
    -    }
    -}
    diff --git a/src/Tokens/DivToken.php b/src/Tokens/DivToken.php
    deleted file mode 100644
    index 09f6baf..0000000
    --- a/src/Tokens/DivToken.php
    +++ /dev/null
    @@ -1,38 +0,0 @@
    -withRules(
    -                new QuoteRule(),
    -                new BoldRule(),
    -                new ItalicRule(),
    -                new LinkRule(),
    -                new ImageRule(),
    -                new TextRule(),
    -            )
    -            ->parse($this->content);
    -
    -        $class = $this->class ? " class=\"{$this->class}\"" : '';
    -
    -        return "{$content}
    "; - } -} diff --git a/src/Tokens/FrontMatterToken.php b/src/Tokens/FrontMatterToken.php deleted file mode 100644 index f44d4a2..0000000 --- a/src/Tokens/FrontMatterToken.php +++ /dev/null @@ -1,18 +0,0 @@ -level}"; - - $slug = $this->content |> trim(...) |> strtolower(...) |> (fn (string $x) => str_replace(' ', '-', $x)); - - $id = " id=\"{$slug}\""; - - return "<{$tag}{$id}>{$this->content}"; - } -} diff --git a/src/Tokens/HtmlToken.php b/src/Tokens/HtmlToken.php deleted file mode 100644 index a000be7..0000000 --- a/src/Tokens/HtmlToken.php +++ /dev/null @@ -1,18 +0,0 @@ -html; - } -} diff --git a/src/Tokens/ImageToken.php b/src/Tokens/ImageToken.php deleted file mode 100644 index 88d9b8d..0000000 --- a/src/Tokens/ImageToken.php +++ /dev/null @@ -1,21 +0,0 @@ -alt ? " alt=\"{$this->alt}\"" : ''; - - return "href}\"{$alt}>"; - } -} diff --git a/src/Tokens/ItalicToken.php b/src/Tokens/ItalicToken.php deleted file mode 100644 index 800be57..0000000 --- a/src/Tokens/ItalicToken.php +++ /dev/null @@ -1,31 +0,0 @@ -withRules( - new BoldRule(), - new StrikethroughRule(), - new LinkRule(), - new TextRule(), - ) - ->parse($this->content); - - return "{$content}"; - } -} diff --git a/src/Tokens/LinkToken.php b/src/Tokens/LinkToken.php deleted file mode 100644 index a8de571..0000000 --- a/src/Tokens/LinkToken.php +++ /dev/null @@ -1,40 +0,0 @@ -withRules( - new BoldRule(), - new ItalicRule(), - new StrikethroughRule(), - new TextRule(), - ) - ->parse($this->content); - - $href = $this->href ?? ''; - $blank = ''; - - if (str_starts_with($href, '*')) { - $href = substr($href, 1); - $blank = ' target="_blank" rel="noopener noreferrer"'; - } - - return "{$content}"; - } -} diff --git a/src/Tokens/ListItem.php b/src/Tokens/ListItem.php deleted file mode 100644 index 1ec19ce..0000000 --- a/src/Tokens/ListItem.php +++ /dev/null @@ -1,13 +0,0 @@ -withRules( - new BoldRule(), - new ItalicRule(), - new LinkRule(), - new ImageRule(), - new CodeRule(), - new TextRule(), - ); - - $list = ''; - - return $list; - } -} diff --git a/src/Tokens/NewLineToken.php b/src/Tokens/NewLineToken.php deleted file mode 100644 index 88a3224..0000000 --- a/src/Tokens/NewLineToken.php +++ /dev/null @@ -1,18 +0,0 @@ -content; - } -} diff --git a/src/Tokens/OrderedListToken.php b/src/Tokens/OrderedListToken.php deleted file mode 100644 index 396211f..0000000 --- a/src/Tokens/OrderedListToken.php +++ /dev/null @@ -1,44 +0,0 @@ -withRules( - new BoldRule(), - new ItalicRule(), - new LinkRule(), - new ImageRule(), - new CodeRule(), - new TextRule(), - ); - - $list = '
      '; - - foreach ($this->items as $item) { - $content = $parser->parse($item->content); - $children = $item->children?->parse($parser) ?? ''; - $list .= "
    1. {$content}{$children}
    2. "; - } - - $list .= '
    '; - - return $list; - } -} diff --git a/src/Tokens/ParagraphToken.php b/src/Tokens/ParagraphToken.php deleted file mode 100644 index c839181..0000000 --- a/src/Tokens/ParagraphToken.php +++ /dev/null @@ -1,35 +0,0 @@ -withRules( - new BoldRule(), - new ItalicRule(), - new LinkRule(), - new ImageRule(), - new CodeRule(), - new TextRule(), - ); - - $content = $parser->parse($this->content); - - return "

    {$content}

    "; - } -} diff --git a/src/Tokens/PreToken.php b/src/Tokens/PreToken.php deleted file mode 100644 index c25a959..0000000 --- a/src/Tokens/PreToken.php +++ /dev/null @@ -1,33 +0,0 @@ -language; - - if (! $language && $parser->highlighter) { - $language = $parser->highlighter->fallbackLanguage?->getName(); - } - - if ($parser->highlighter) { - $content = $parser->highlighter->parse($this->content, $language); - } else { - $content = $this->content; - } - - $class = $language ? " class=\"language-{$language}\"" : ''; - - return "
    {$content}
    "; - } -} diff --git a/src/Tokens/QuoteToken.php b/src/Tokens/QuoteToken.php deleted file mode 100644 index d3c5438..0000000 --- a/src/Tokens/QuoteToken.php +++ /dev/null @@ -1,35 +0,0 @@ -withRules( - new QuoteRule(), - new BoldRule(), - new ItalicRule(), - new LinkRule(), - new ImageRule(), - new TextRule(), - ) - ->parse($this->content); - - return "
    {$content}
    "; - } -} diff --git a/src/Tokens/RulerToken.php b/src/Tokens/RulerToken.php deleted file mode 100644 index fc2159d..0000000 --- a/src/Tokens/RulerToken.php +++ /dev/null @@ -1,19 +0,0 @@ -'; - } -} diff --git a/src/Tokens/RulerType.php b/src/Tokens/RulerType.php deleted file mode 100644 index 04393d7..0000000 --- a/src/Tokens/RulerType.php +++ /dev/null @@ -1,9 +0,0 @@ -withRules( - new ItalicRule(), - new BoldRule(), - new LinkRule(), - new TextRule(), - ) - ->parse($this->content); - - return "{$content}"; - } -} diff --git a/src/Tokens/TableRow.php b/src/Tokens/TableRow.php deleted file mode 100644 index 77ef2c2..0000000 --- a/src/Tokens/TableRow.php +++ /dev/null @@ -1,12 +0,0 @@ -withRules( - new BoldRule(), - new ItalicRule(), - new LinkRule(), - new CodeRule(), - new ImageRule(), - new TextRule(), - ); - - $headerRows = array_values(array_filter($this->rows, fn (TableRow $row) => $row->isHeader)); - $dataRows = array_values(array_filter($this->rows, fn (TableRow $row) => ! $row->isHeader)); - - $table = ''; - - if ($headerRows !== []) { - $table .= ''; - - foreach ($headerRows as $row) { - $table .= ''; - - foreach ($row->cells as $cell) { - $table .= ''; - } - - $table .= ''; - } - - $table .= ''; - } - - if ($dataRows !== []) { - $table .= ''; - - foreach ($dataRows as $row) { - $table .= ''; - - foreach ($row->cells as $cell) { - $table .= ''; - } - - $table .= ''; - } - - $table .= ''; - } - - $table .= '
    ' . $parser->parse($cell)->html . '
    ' . $parser->parse($cell)->html . '
    '; - - return $table; - } -} diff --git a/src/Tokens/TextToken.php b/src/Tokens/TextToken.php deleted file mode 100644 index 98cfef4..0000000 --- a/src/Tokens/TextToken.php +++ /dev/null @@ -1,23 +0,0 @@ -content; - } - - public function append(string $content): void - { - $this->content .= $content; - } -} diff --git a/tests/Bench/MarkdownBench.php b/tests/Bench/MarkdownBench.php index 659ce67..0f782ef 100644 --- a/tests/Bench/MarkdownBench.php +++ b/tests/Bench/MarkdownBench.php @@ -9,11 +9,15 @@ use PhpBench\Attributes as Bench; use Tempest\Markdown\Parser; -#[Bench\Warmup(1)] -#[Bench\RetryThreshold(5)] +// Each iteration is a fresh subprocess, so JIT must warm up inside Warmup. +// phpbench.json drops opcache.jit_hot_func/loop to 2 so JIT settles within a +// handful of calls; Warmup(15) covers it, Revs(15)/Iterations(3) keeps stddev +// tight without blowing up wall time. +#[Bench\Warmup(15)] +#[Bench\RetryThreshold(2)] #[Bench\OutputTimeUnit('milliseconds', 3)] -#[Bench\Iterations(5)] -#[Bench\Revs(3)] +#[Bench\Iterations(3)] +#[Bench\Revs(15)] #[Bench\ParamProviders('provideFiles')] final readonly class MarkdownBench { diff --git a/tests/BoldTest.php b/tests/BoldTest.php new file mode 100644 index 0000000..a3d572a --- /dev/null +++ b/tests/BoldTest.php @@ -0,0 +1,66 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function double_asterisk(): void + { + $this->assertSame( + '

    bold

    ', + $this->parser->parse('**bold**')->html, + ); + } + + #[Test] + public function single_asterisk(): void + { + $this->assertSame( + '

    bold

    ', + $this->parser->parse('*bold*')->html, + ); + } + + #[Test] + public function bold_containing_italic(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('**hello __world__**')->html, + ); + } + + #[Test] + public function bold_containing_strikethrough(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('**hello ~~world~~**')->html, + ); + } + + #[Test] + public function bold_containing_link(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('**hello [world](#)**')->html, + ); + } +} diff --git a/tests/CodeTest.php b/tests/CodeTest.php new file mode 100644 index 0000000..3e45c57 --- /dev/null +++ b/tests/CodeTest.php @@ -0,0 +1,45 @@ +assertSame( + '

    $foo

    ', + $parser->parse('`$foo`')->html, + ); + } + + #[Test] + public function inline_code_with_explicit_language_emits_language_class(): void + { + // Without highlighter, no syntax highlighting is applied but the class is still set. + $parser = new Parser(highlighter: null); + $this->assertSame( + '

    echo "hi";

    ', + $parser->parse('`{php}echo "hi";`')->html, + ); + } + + #[Test] + public function inline_code_without_highlighter_omits_language_class(): void + { + // Without highlighter and without explicit language, no class is emitted. + $parser = new Parser(highlighter: null); + $this->assertSame( + '

    code

    ', + $parser->parse('`code`')->html, + ); + } +} diff --git a/tests/DivTest.php b/tests/DivTest.php new file mode 100644 index 0000000..332b3e0 --- /dev/null +++ b/tests/DivTest.php @@ -0,0 +1,84 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function without_class(): void + { + $this->assertSame( + "
    Hello\n
    ", + $this->parser->parse(":::\nHello\n:::")->html, + ); + } + + #[Test] + public function with_class(): void + { + $this->assertSame( + "
    Hello\n
    ", + $this->parser->parse(":::warning\nHello\n:::")->html, + ); + } + + #[Test] + public function with_multiple_classes(): void + { + $this->assertSame( + "
    Hello\n
    ", + $this->parser->parse(":::foo bar\nHello\n:::")->html, + ); + } + + #[Test] + public function with_inline_bold(): void + { + $this->assertSame( + "
    Hello world\n
    ", + $this->parser->parse(":::\nHello **world**\n:::")->html, + ); + } + + #[Test] + public function with_inline_italic(): void + { + $this->assertSame( + "
    Hello world\n
    ", + $this->parser->parse(":::\nHello __world__\n:::")->html, + ); + } + + #[Test] + public function with_inline_link(): void + { + $this->assertSame( + "
    Hello world\n
    ", + $this->parser->parse(":::\nHello [world](#)\n:::")->html, + ); + } + + #[Test] + public function multiline_content(): void + { + $this->assertSame( + "
    line one\nline two\n
    ", + $this->parser->parse(":::warning\nline one\nline two\n:::")->html, + ); + } +} diff --git a/tests/FrontMatterTest.php b/tests/FrontMatterTest.php new file mode 100644 index 0000000..ce2a0bf --- /dev/null +++ b/tests/FrontMatterTest.php @@ -0,0 +1,79 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function basic_yaml_extracted_and_html_omits_front_matter(): void + { + $input = <<<'MD' + --- + title: Hello + foo: bar + --- + + Bar + MD; + + $parsed = $this->parser->parse($input); + + $this->assertSame('

    Bar

    ', $parsed->html); + $this->assertSame(['title' => 'Hello', 'foo' => 'bar'], $parsed->frontMatter); + } + + #[Test] + public function longer_delimiter_lines(): void + { + $input = <<<'MD' + ----- + title: Hello + foo: bar + ----- + + Bar + MD; + + $parsed = $this->parser->parse($input); + + $this->assertSame('

    Bar

    ', $parsed->html); + $this->assertSame(['title' => 'Hello', 'foo' => 'bar'], $parsed->frontMatter); + } + + #[Test] + public function multiline_quoted_yaml_value(): void + { + $input = <<<'MD' + --- + title: Introduction + description: "Tempest is a framework for PHP development, designed to get out of your way. + Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework." + --- + + Bar + MD; + + $parsed = $this->parser->parse($input); + + $this->assertSame('

    Bar

    ', $parsed->html); + $this->assertSame([ + 'title' => 'Introduction', + 'description' => 'Tempest is a framework for PHP development, designed to get out of your way. Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework.', + ], $parsed->frontMatter); + } +} diff --git a/tests/HeadingTest.php b/tests/HeadingTest.php new file mode 100644 index 0000000..27c95c6 --- /dev/null +++ b/tests/HeadingTest.php @@ -0,0 +1,76 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function h1(): void + { + $this->assertSame( + '

    Hello

    ', + $this->parser->parse('# Hello')->html, + ); + } + + #[Test] + public function h2(): void + { + $this->assertSame( + '

    Hello

    ', + $this->parser->parse('## Hello')->html, + ); + } + + #[Test] + public function h3(): void + { + $this->assertSame( + '

    Hello

    ', + $this->parser->parse('### Hello')->html, + ); + } + + #[Test] + public function h6(): void + { + $this->assertSame( + '
    Hello World
    ', + $this->parser->parse('###### Hello World')->html, + ); + } + + #[Test] + public function slug_lowercases_and_dashes_spaces(): void + { + $this->assertSame( + '

    Hello World

    ', + $this->parser->parse('## Hello World')->html, + ); + } + + #[Test] + public function heading_content_is_emitted_raw_without_inline_parsing(): void + { + // HeadingToken did not apply inline parsing to its content; markers render literally. + $this->assertSame( + '

    **bold**

    ', + $this->parser->parse('# **bold**')->html, + ); + } +} diff --git a/tests/HtmlTest.php b/tests/HtmlTest.php new file mode 100644 index 0000000..ff64b93 --- /dev/null +++ b/tests/HtmlTest.php @@ -0,0 +1,66 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function html_at_block_start_passes_through(): void + { + $this->assertSame( + '

    Hi

    ', + $this->parser->parse('

    Hi

    ')->html, + ); + } + + #[Test] + public function nested_same_name_tags_are_balanced(): void + { + $this->assertSame( + '
    Hi
    ', + $this->parser->parse('
    Hi
    ')->html, + ); + } + + #[Test] + public function html_inside_paragraph_is_inlined(): void + { + // A `
    `-style tag appearing mid-paragraph is consumed as paragraph content. + $this->assertSame( + '

    paragraph with
    break

    ', + $this->parser->parse('paragraph with
    break')->html, + ); + } + + #[Test] + public function html_block_after_paragraph(): void + { + $input = <<<'MD' + Hello + +

    + Hi +

    + + World + MD; + + $this->assertStringContainsString('

    Hello', $this->parser->parse($input)->html); + $this->assertStringContainsString("

    \nHi\n

    ", $this->parser->parse($input)->html); + } +} diff --git a/tests/ImageTest.php b/tests/ImageTest.php new file mode 100644 index 0000000..77bff8f --- /dev/null +++ b/tests/ImageTest.php @@ -0,0 +1,49 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function image_with_alt(): void + { + $this->assertSame( + '

    a cat

    ', + $this->parser->parse('![a cat](https://example.com/img.png)')->html, + ); + } + + #[Test] + public function image_without_alt(): void + { + $this->assertSame( + '

    ', + $this->parser->parse('![](https://example.com/img.png)')->html, + ); + } + + #[Test] + public function bare_exclamation_passes_through(): void + { + // A `!` not followed by `[` is not an image trigger and must render as text. + $this->assertSame( + '

    hello!

    ', + $this->parser->parse('hello!')->html, + ); + } +} diff --git a/tests/ItalicTest.php b/tests/ItalicTest.php new file mode 100644 index 0000000..9bb805d --- /dev/null +++ b/tests/ItalicTest.php @@ -0,0 +1,66 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function double_underscore(): void + { + $this->assertSame( + '

    italic

    ', + $this->parser->parse('__italic__')->html, + ); + } + + #[Test] + public function single_underscore(): void + { + $this->assertSame( + '

    italic

    ', + $this->parser->parse('_italic_')->html, + ); + } + + #[Test] + public function italic_containing_bold(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('__hello **world**__')->html, + ); + } + + #[Test] + public function italic_containing_strikethrough(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('__hello ~~world~~__')->html, + ); + } + + #[Test] + public function italic_containing_link(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('__hello [world](#)__')->html, + ); + } +} diff --git a/tests/LexerRules/BoldRuleTest.php b/tests/LexerRules/BoldRuleTest.php deleted file mode 100644 index 835b25b..0000000 --- a/tests/LexerRules/BoldRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex('**bold**')[0]; - - $this->assertEquals(new BoldToken('bold'), $token); - } - - #[Test] - public function test_lex_single_asterisk(): void - { - $token = new Lexer([new BoldRule()])->lex('*bold*')[0]; - - $this->assertEquals(new BoldToken('bold'), $token); - } -} diff --git a/tests/LexerRules/CodeRuleTest.php b/tests/LexerRules/CodeRuleTest.php deleted file mode 100644 index ea72afe..0000000 --- a/tests/LexerRules/CodeRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex('`code`')[0]; - - $this->assertEquals(new CodeToken(null, 'code'), $token); - } - - #[Test] - public function test_lex_with_language(): void - { - $token = new Lexer([new CodeRule()])->lex('`{php}code`')[0]; - - $this->assertEquals(new CodeToken('php', 'code'), $token); - } -} diff --git a/tests/LexerRules/DivRuleTest.php b/tests/LexerRules/DivRuleTest.php deleted file mode 100644 index e7042ca..0000000 --- a/tests/LexerRules/DivRuleTest.php +++ /dev/null @@ -1,44 +0,0 @@ -lex(":::\nHello\n:::\n")[0]; - - $this->assertEquals(new DivToken(class: null, content: "Hello\n"), $token); - } - - #[Test] - public function test_lex_with_class(): void - { - $token = new Lexer([new DivRule()])->lex(":::warning\nHello\n:::\n")[0]; - - $this->assertEquals(new DivToken(class: 'warning', content: "Hello\n"), $token); - } - - #[Test] - public function test_lex_with_multiple_classes(): void - { - $token = new Lexer([new DivRule()])->lex(":::foo bar\nHello\n:::\n")[0]; - - $this->assertEquals(new DivToken(class: 'foo bar', content: "Hello\n"), $token); - } - - #[Test] - public function test_lex_multiline_content(): void - { - $token = new Lexer([new DivRule()])->lex(":::warning\nline one\nline two\n:::\n")[0]; - - $this->assertEquals(new DivToken(class: 'warning', content: "line one\nline two\n"), $token); - } -} diff --git a/tests/LexerRules/FrontMatterRuleTest.php b/tests/LexerRules/FrontMatterRuleTest.php deleted file mode 100644 index 4284046..0000000 --- a/tests/LexerRules/FrontMatterRuleTest.php +++ /dev/null @@ -1,73 +0,0 @@ -lex(<<<'MD' - --- - title: Hello - foo: bar - --- - - Bar - MD); - - $this->assertCount(2, $tokens); - $this->assertEquals(new FrontMatterToken(['title' => 'Hello', 'foo' => 'bar']), $tokens[0]); - $this->assertEquals(new ParagraphToken('Bar'), $tokens[1]); - } - - #[Test] - public function test_lex_with_longer_frontmatter_lines(): void - { - $tokens = new Lexer([new FrontMatterRule(), new NewLineRule(), new ParagraphRule()])->lex(<<<'MD' - ----- - title: Hello - foo: bar - ----- - - Bar - MD); - - $this->assertCount(2, $tokens); - $this->assertEquals(new FrontMatterToken(['title' => 'Hello', 'foo' => 'bar']), $tokens[0]); - $this->assertEquals(new ParagraphToken('Bar'), $tokens[1]); - } - - #[Test] - public function test_complex_frontmatter(): void - { - $tokens = new Lexer([new FrontMatterRule(), new NewLineRule(), new ParagraphRule()])->lex(<<<'MD' - --- - title: Introduction - description: "Tempest is a framework for PHP development, designed to get out of your way. - Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework." - --- - - Bar - MD); - - $this->assertCount(2, $tokens); - $this->assertEquals( - new FrontMatterToken([ - 'title' => 'Introduction', - 'description' => 'Tempest is a framework for PHP development, designed to get out of your way. Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework.', - ]), - $tokens[0], - ); - $this->assertEquals(new ParagraphToken('Bar'), $tokens[1]); - } -} diff --git a/tests/LexerRules/HeadingRuleTest.php b/tests/LexerRules/HeadingRuleTest.php deleted file mode 100644 index 276bc78..0000000 --- a/tests/LexerRules/HeadingRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex('# Hello')[0]; - - $this->assertEquals(new HeadingToken('Hello', 1), $token); - } - - #[Test] - public function test_lex_deep_heading(): void - { - $token = new Lexer([new HeadingRule()])->lex('### Hello')[0]; - - $this->assertEquals(new HeadingToken('Hello', 3), $token); - } -} diff --git a/tests/LexerRules/HtmlRuleTest.php b/tests/LexerRules/HtmlRuleTest.php deleted file mode 100644 index f686e46..0000000 --- a/tests/LexerRules/HtmlRuleTest.php +++ /dev/null @@ -1,47 +0,0 @@ -lex('

    Hi

    ')[0]; - - $this->assertEquals(new HtmlToken('

    Hi

    '), $token); - } - - #[Test] - public function test_lex_nested(): void - { - $token = new Lexer([new HtmlRule()])->lex('
    Hi
    ')[0]; - - $this->assertEquals(new HtmlToken('
    Hi
    '), $token); - } - - #[Test] - public function test_lex_multiline(): void - { - $html = <<<'HTML' - Hello -

    - Hi -

    - World - HTML; - - $tokens = new Lexer([new NewLineRule(), new HtmlRule(), new ParagraphRule()])->lex($html); - - $this->assertCount(3, $tokens); - $this->assertEquals(new HtmlToken("

    \nHi\n

    \n"), $tokens[1]); - } -} diff --git a/tests/LexerRules/ImageRuleTest.php b/tests/LexerRules/ImageRuleTest.php deleted file mode 100644 index 8c7efe3..0000000 --- a/tests/LexerRules/ImageRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex('![alt](href)')[0]; - - $this->assertEquals(new ImageToken('href', 'alt'), $token); - } - - #[Test] - public function test_lex_without_alt(): void - { - $token = new Lexer([new ImageRule()])->lex('![](href)')[0]; - - $this->assertEquals(new ImageToken('href', null), $token); - } -} diff --git a/tests/LexerRules/ItalicRuleTest.php b/tests/LexerRules/ItalicRuleTest.php deleted file mode 100644 index a5fc09d..0000000 --- a/tests/LexerRules/ItalicRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex('__italic__')[0]; - - $this->assertEquals(new ItalicToken('italic'), $token); - } - - #[Test] - public function test_lex_single_underscore(): void - { - $token = new Lexer([new ItalicRule()])->lex('_italic_')[0]; - - $this->assertEquals(new ItalicToken('italic'), $token); - } -} diff --git a/tests/LexerRules/LinkRuleTest.php b/tests/LexerRules/LinkRuleTest.php deleted file mode 100644 index aba6c8e..0000000 --- a/tests/LexerRules/LinkRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex('[click here](#)')[0]; - - $this->assertEquals(new LinkToken('click here', '#'), $token); - } - - #[Test] - public function test_lex_without_href(): void - { - $token = new Lexer([new LinkRule()])->lex('[click here]')[0]; - - $this->assertEquals(new LinkToken('click here', null), $token); - } -} diff --git a/tests/LexerRules/ListRuleTest.php b/tests/LexerRules/ListRuleTest.php deleted file mode 100644 index a34fb3c..0000000 --- a/tests/LexerRules/ListRuleTest.php +++ /dev/null @@ -1,73 +0,0 @@ -lex("- item\n")[0]; - - $this->assertEquals(new ListToken([new ListItem('item')]), $token); - } - - #[Test] - public function test_lex_multiple_items(): void - { - $token = new Lexer([new ListRule()])->lex("- one\n- two\n")[0]; - - $this->assertEquals(new ListToken([new ListItem('one'), new ListItem('two')]), $token); - } - - #[Test] - public function test_lex_nested(): void - { - $token = new Lexer([new ListRule()])->lex("- parent\n - child\n")[0]; - - $expected = new ListToken([ - new ListItem('parent', new ListToken([ - new ListItem('child'), - ])), - ]); - - $this->assertEquals($expected, $token); - } - - #[Test] - public function test_lex_nested_multiple_children(): void - { - $token = new Lexer([new ListRule()])->lex("- parent\n - child one\n - child two\n")[0]; - - $expected = new ListToken([ - new ListItem('parent', new ListToken([ - new ListItem('child one'), - new ListItem('child two'), - ])), - ]); - - $this->assertEquals($expected, $token); - } - - #[Test] - public function test_lex_nested_sibling_after_sublist(): void - { - $token = new Lexer([new ListRule()])->lex("- one\n - child\n- two\n")[0]; - - $expected = new ListToken([ - new ListItem('one', new ListToken([ - new ListItem('child'), - ])), - new ListItem('two'), - ]); - - $this->assertEquals($expected, $token); - } -} diff --git a/tests/LexerRules/NewLineRuleTest.php b/tests/LexerRules/NewLineRuleTest.php deleted file mode 100644 index ba1f1cf..0000000 --- a/tests/LexerRules/NewLineRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex("\n")[0]; - - $this->assertEquals(new NewLineToken("\n"), $token); - } - - #[Test] - public function test_lex_multiple_newlines(): void - { - $token = new Lexer([new NewLineRule()])->lex("\n\n\n")[0]; - - $this->assertEquals(new NewLineToken("\n\n\n"), $token); - } -} diff --git a/tests/LexerRules/OrderedListRuleTest.php b/tests/LexerRules/OrderedListRuleTest.php deleted file mode 100644 index 5877ff1..0000000 --- a/tests/LexerRules/OrderedListRuleTest.php +++ /dev/null @@ -1,81 +0,0 @@ -lex("1. item\n")[0]; - - $this->assertEquals(new OrderedListToken([new ListItem('item')]), $token); - } - - #[Test] - public function test_lex_multiple_items(): void - { - $token = new Lexer([new OrderedListRule()])->lex("1. one\n2. two\n")[0]; - - $this->assertEquals(new OrderedListToken([new ListItem('one'), new ListItem('two')]), $token); - } - - #[Test] - public function test_lex_multi_digit_numbers(): void - { - $token = new Lexer([new OrderedListRule()])->lex("10. ten\n11. eleven\n")[0]; - - $this->assertEquals(new OrderedListToken([new ListItem('ten'), new ListItem('eleven')]), $token); - } - - #[Test] - public function test_lex_nested(): void - { - $token = new Lexer([new OrderedListRule()])->lex("1. parent\n 1. child\n")[0]; - - $expected = new OrderedListToken([ - new ListItem('parent', new OrderedListToken([ - new ListItem('child'), - ])), - ]); - - $this->assertEquals($expected, $token); - } - - #[Test] - public function test_lex_nested_multiple_children(): void - { - $token = new Lexer([new OrderedListRule()])->lex("1. parent\n 1. child one\n 2. child two\n")[0]; - - $expected = new OrderedListToken([ - new ListItem('parent', new OrderedListToken([ - new ListItem('child one'), - new ListItem('child two'), - ])), - ]); - - $this->assertEquals($expected, $token); - } - - #[Test] - public function test_lex_nested_sibling_after_sublist(): void - { - $token = new Lexer([new OrderedListRule()])->lex("1. one\n 1. child\n2. two\n")[0]; - - $expected = new OrderedListToken([ - new ListItem('one', new OrderedListToken([ - new ListItem('child'), - ])), - new ListItem('two'), - ]); - - $this->assertEquals($expected, $token); - } -} diff --git a/tests/LexerRules/ParagraphRuleTest.php b/tests/LexerRules/ParagraphRuleTest.php deleted file mode 100644 index 9be7727..0000000 --- a/tests/LexerRules/ParagraphRuleTest.php +++ /dev/null @@ -1,20 +0,0 @@ -lex("Hello, world!\n")[0]; - - $this->assertEquals(new ParagraphToken("Hello, world!\n"), $token); - } -} diff --git a/tests/LexerRules/PreRuleTest.php b/tests/LexerRules/PreRuleTest.php deleted file mode 100644 index 8aa7925..0000000 --- a/tests/LexerRules/PreRuleTest.php +++ /dev/null @@ -1,48 +0,0 @@ -lex(<<<'MD' - ```php - echo "hi"; - ``` - MD)[0]; - - $this->assertEquals(new PreToken(language: 'php', content: 'echo "hi";'), $token); - } - - #[Test] - public function test_lex_without_language(): void - { - $token = new Lexer([new PreRule()])->lex(<<<'MD' - ``` - echo "hi"; - ``` - MD)[0]; - - $this->assertEquals(new PreToken(language: null, content: 'echo "hi";'), $token); - } - - #[Test] - public function test_lex_with_backtick_in_content(): void - { - $token = new Lexer([new PreRule()])->lex(<<<'MD' - ```php - echo `uname`; - ``` - MD)[0]; - - $this->assertEquals(new PreToken(language: 'php', content: 'echo `uname`;'), $token); - } -} diff --git a/tests/LexerRules/QuoteRuleTest.php b/tests/LexerRules/QuoteRuleTest.php deleted file mode 100644 index d4e82fb..0000000 --- a/tests/LexerRules/QuoteRuleTest.php +++ /dev/null @@ -1,38 +0,0 @@ -lex('> quote')[0]; - - $this->assertSame('quote', $token->content); - } - - #[Test] - public function test_lex_multiline(): void - { - /** @var QuoteToken $token */ - $token = new Lexer([new QuoteRule()])->lex(<<<'MD' - > line 1 - > > line 2 - > line 3 - MD)[0]; - - $this->assertSame(<<<'TXT' - line 1 - > line 2 - line 3 - TXT, $token->content); - } -} diff --git a/tests/LexerRules/StrikethroughRuleTest.php b/tests/LexerRules/StrikethroughRuleTest.php deleted file mode 100644 index 82ebc3f..0000000 --- a/tests/LexerRules/StrikethroughRuleTest.php +++ /dev/null @@ -1,28 +0,0 @@ -lex('~~strikethrough~~')[0]; - - $this->assertEquals(new StrikethroughToken('strikethrough'), $token); - } - - #[Test] - public function test_lex_single_tilde(): void - { - $token = new Lexer([new StrikethroughRule()])->lex('~strikethrough~')[0]; - - $this->assertEquals(new StrikethroughToken('strikethrough'), $token); - } -} diff --git a/tests/LexerRules/TableRuleTest.php b/tests/LexerRules/TableRuleTest.php deleted file mode 100644 index 62c9655..0000000 --- a/tests/LexerRules/TableRuleTest.php +++ /dev/null @@ -1,69 +0,0 @@ -lex("| A | B |\n| --- | --- |")[0]; - - $this->assertEquals( - new TableToken([ - new TableRow(['A', 'B'], isHeader: true), - ]), - $token, - ); - } - - #[Test] - public function test_lex_full_table(): void - { - $token = new Lexer([new TableRule()])->lex("| A | B |\n| --- | --- |\n| 1 | 2 |")[0]; - - $this->assertEquals( - new TableToken([ - new TableRow(['A', 'B'], isHeader: true), - new TableRow(['1', '2'], isHeader: false), - ]), - $token, - ); - } - - #[Test] - public function test_lex_multiple_data_rows(): void - { - $token = new Lexer([new TableRule()])->lex("| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |")[0]; - - $this->assertEquals( - new TableToken([ - new TableRow(['A', 'B'], isHeader: true), - new TableRow(['1', '2'], isHeader: false), - new TableRow(['3', '4'], isHeader: false), - ]), - $token, - ); - } - - #[Test] - public function test_lex_separator_with_alignment(): void - { - $token = new Lexer([new TableRule()])->lex("| A | B | C |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |")[0]; - - $this->assertEquals( - new TableToken([ - new TableRow(['A', 'B', 'C'], isHeader: true), - new TableRow(['1', '2', '3'], isHeader: false), - ]), - $token, - ); - } -} diff --git a/tests/LexerRules/TextRuleTest.php b/tests/LexerRules/TextRuleTest.php deleted file mode 100644 index 23dcda0..0000000 --- a/tests/LexerRules/TextRuleTest.php +++ /dev/null @@ -1,32 +0,0 @@ -lex('hello')[0]; - - $this->assertEquals(new TextToken('hello'), $token); - } - - #[Test] - public function test_lex_appends_to_previous_text_token(): void - { - $tokens = new Lexer([new BoldRule(), new TextRule()])->lex('Hello **world**!'); - - $this->assertEquals(new TextToken('Hello '), $tokens[0]); - $this->assertEquals(new BoldToken('world'), $tokens[1]); - $this->assertEquals(new TextToken('!'), $tokens[2]); - } -} diff --git a/tests/LexerRules/ThickRulerRuleTest.php b/tests/LexerRules/ThickRulerRuleTest.php deleted file mode 100644 index 3f4ef7e..0000000 --- a/tests/LexerRules/ThickRulerRuleTest.php +++ /dev/null @@ -1,29 +0,0 @@ -lex('===')[0]; - - $this->assertEquals(new RulerToken('===', RulerType::THICK), $token); - } - - #[Test] - public function test_lex_long(): void - { - $token = new Lexer([new ThickRulerRule()])->lex('=====')[0]; - - $this->assertEquals(new RulerToken('=====', RulerType::THICK), $token); - } -} diff --git a/tests/LexerRules/ThinRulerRuleTest.php b/tests/LexerRules/ThinRulerRuleTest.php deleted file mode 100644 index 043850e..0000000 --- a/tests/LexerRules/ThinRulerRuleTest.php +++ /dev/null @@ -1,29 +0,0 @@ -lex('---')[0]; - - $this->assertEquals(new RulerToken('---', RulerType::THIN), $token); - } - - #[Test] - public function test_lex_long(): void - { - $token = new Lexer([new ThinRulerRule()])->lex('-----')[0]; - - $this->assertEquals(new RulerToken('-----', RulerType::THIN), $token); - } -} diff --git a/tests/LexerTest.php b/tests/LexerTest.php deleted file mode 100644 index 483e78d..0000000 --- a/tests/LexerTest.php +++ /dev/null @@ -1,57 +0,0 @@ -lexer = new Lexer(); - } - - #[Test] - public function test_lex_snippet(): void - { - $tokens = $this->lexer->lex(<<<'MD' - # Test - Hello **world** - MD); - - $this->assertTokens( - expected: [ - new HeadingToken('Test', 1), - new NewLineToken("\n"), - new ParagraphToken('Hello **world**'), - ], - actual: $tokens, - ); - } - - private function assertTokens(array $expected, TokenCollection $actual): void - { - $this->assertCount(count($expected), $actual); - - foreach ($actual as $i => $token) { - /** @var Token $expected */ - $expectedToken = $expected[$i]; - $actualProperties = (array) $token; - $expectedProperties = (array) $expectedToken; - - $this->assertSame($token::class, $expectedToken::class); - $this->assertSame($expectedProperties, $actualProperties); - } - } -} diff --git a/tests/LinkTest.php b/tests/LinkTest.php new file mode 100644 index 0000000..c1caa1a --- /dev/null +++ b/tests/LinkTest.php @@ -0,0 +1,75 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function link_with_href(): void + { + $this->assertSame( + '

    click here

    ', + $this->parser->parse('[click here](#)')->html, + ); + } + + #[Test] + public function link_without_href(): void + { + $this->assertSame( + '

    click here

    ', + $this->parser->parse('[click here]')->html, + ); + } + + #[Test] + public function link_containing_bold(): void + { + $this->assertSame( + '

    click here

    ', + $this->parser->parse('[click **here**](#)')->html, + ); + } + + #[Test] + public function link_containing_italic(): void + { + $this->assertSame( + '

    click here

    ', + $this->parser->parse('[click __here__](#)')->html, + ); + } + + #[Test] + public function link_containing_strikethrough(): void + { + $this->assertSame( + '

    click here

    ', + $this->parser->parse('[click ~~here~~](#)')->html, + ); + } + + #[Test] + public function asterisk_prefix_opens_new_tab(): void + { + $this->assertSame( + '

    click here

    ', + $this->parser->parse('[click here](*https://tempestphp.com)')->html, + ); + } +} diff --git a/tests/ListTest.php b/tests/ListTest.php new file mode 100644 index 0000000..33c96dd --- /dev/null +++ b/tests/ListTest.php @@ -0,0 +1,140 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function single_item(): void + { + $this->assertSame( + '', + $this->parser->parse('- item')->html, + ); + } + + #[Test] + public function multiple_items(): void + { + $this->assertSame( + '', + $this->parser->parse("- one\n- two\n- three")->html, + ); + } + + #[Test] + public function item_with_bold(): void + { + $this->assertSame( + '', + $this->parser->parse('- hello **world**')->html, + ); + } + + #[Test] + public function item_with_italic(): void + { + $this->assertSame( + '', + $this->parser->parse('- hello __world__')->html, + ); + } + + #[Test] + public function item_with_link(): void + { + $this->assertSame( + '', + $this->parser->parse('- [world](#)')->html, + ); + } + + #[Test] + public function item_with_inline_code(): void + { + $this->assertSame( + '', + $this->parser->parse('- run `php tempest`')->html, + ); + } + + #[Test] + public function nested_single_child(): void + { + $this->assertSame( + '', + $this->parser->parse("- parent\n - child")->html, + ); + } + + #[Test] + public function nested_multiple_children(): void + { + $this->assertSame( + '', + $this->parser->parse("- parent\n - child one\n - child two")->html, + ); + } + + #[Test] + public function nested_sibling_after_sublist(): void + { + $this->assertSame( + '', + $this->parser->parse("- one\n - child\n- two")->html, + ); + } + + #[Test] + public function three_levels_of_nesting(): void + { + $this->assertSame( + '', + $this->parser->parse("- a\n - b\n - c")->html, + ); + } + + #[Test] + public function bare_dash_followed_by_digit_is_paragraph_not_list(): void + { + // Bug fix vs. the original ListRule, which triggered on any `-`. + $this->assertSame( + '

    -2 is negative two

    ', + $this->parser->parse('-2 is negative two')->html, + ); + } + + #[Test] + public function dash_with_no_space_is_paragraph(): void + { + $this->assertSame( + '

    -foo

    ', + $this->parser->parse('-foo')->html, + ); + } + + #[Test] + public function thin_ruler_still_works(): void + { + // Three dashes must remain a horizontal rule, not a list of '--'. + $this->assertSame( + "

    x\n


    ", + $this->parser->parse("x\n---")->html, + ); + } +} diff --git a/tests/OrderedListTest.php b/tests/OrderedListTest.php new file mode 100644 index 0000000..177ee5a --- /dev/null +++ b/tests/OrderedListTest.php @@ -0,0 +1,112 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function single_item(): void + { + $this->assertSame( + '
    1. item
    ', + $this->parser->parse('1. item')->html, + ); + } + + #[Test] + public function multiple_items(): void + { + $this->assertSame( + '
    1. one
    2. two
    3. three
    ', + $this->parser->parse("1. one\n2. two\n3. three")->html, + ); + } + + #[Test] + public function multi_digit_marker(): void + { + $this->assertSame( + '
    1. tenth
    ', + $this->parser->parse('10. tenth')->html, + ); + } + + #[Test] + public function item_with_bold(): void + { + $this->assertSame( + '
    1. hello world
    ', + $this->parser->parse('1. hello **world**')->html, + ); + } + + #[Test] + public function item_with_link(): void + { + $this->assertSame( + '
    1. world
    ', + $this->parser->parse('1. [world](#)')->html, + ); + } + + #[Test] + public function nested(): void + { + $this->assertSame( + '
    1. parent
      1. child
    ', + $this->parser->parse("1. parent\n 2. child")->html, + ); + } + + #[Test] + public function nested_sibling_after_sublist(): void + { + $this->assertSame( + '
    1. one
      1. child
    2. two
    ', + $this->parser->parse("1. one\n 2. child\n3. two")->html, + ); + } + + #[Test] + public function decimal_number_is_paragraph_not_list(): void + { + // Bug fix vs. the original OrderedListRule, which triggered on any digit. + $this->assertSame( + '

    1.5 is a number

    ', + $this->parser->parse('1.5 is a number')->html, + ); + } + + #[Test] + public function digit_followed_by_dot_no_space_is_paragraph(): void + { + $this->assertSame( + '

    1.foo

    ', + $this->parser->parse('1.foo')->html, + ); + } + + #[Test] + public function digit_with_no_dot_is_paragraph(): void + { + $this->assertSame( + '

    1 item

    ', + $this->parser->parse('1 item')->html, + ); + } +} diff --git a/tests/ParagraphTest.php b/tests/ParagraphTest.php new file mode 100644 index 0000000..6827519 --- /dev/null +++ b/tests/ParagraphTest.php @@ -0,0 +1,86 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function plain_text(): void + { + $this->assertSame( + '

    Hello, world!

    ', + $this->parser->parse('Hello, world!')->html, + ); + } + + #[Test] + public function paragraph_with_bold(): void + { + $this->assertSame( + '

    Hello, world!

    ', + $this->parser->parse('Hello, **world**!')->html, + ); + } + + #[Test] + public function paragraph_with_italic(): void + { + $this->assertSame( + '

    Hello, world!

    ', + $this->parser->parse('Hello, __world__!')->html, + ); + } + + #[Test] + public function paragraph_with_link(): void + { + $this->assertSame( + '

    Hello, world!

    ', + $this->parser->parse('Hello, [world](#)!')->html, + ); + } + + #[Test] + public function paragraph_with_image(): void + { + $this->assertSame( + '

    Hello, world!

    ', + $this->parser->parse('Hello, ![world](#)!')->html, + ); + } + + #[Test] + public function paragraph_with_inline_code(): void + { + $this->assertSame( + '

    Hello, world!

    ', + $this->parser->parse('Hello, `world`!')->html, + ); + } + + #[Test] + public function paragraph_swallows_trailing_blank_lines(): void + { + // ParagraphRule consumes trailing newlines into the paragraph content, + // so `

    ` includes the literal blank lines. + $this->assertSame( + "

    Hello\n\n

    World

    ", + $this->parser->parse("Hello\n\nWorld")->html, + ); + } +} diff --git a/tests/PreTest.php b/tests/PreTest.php new file mode 100644 index 0000000..a00a86b --- /dev/null +++ b/tests/PreTest.php @@ -0,0 +1,47 @@ +assertSame( + '
    echo "hi";
    ', + $parser->parse($input)->html, + ); + } + + #[Test] + public function fenced_code_without_language_no_highlighter(): void + { + // Without highlighter and without explicit language, no class is emitted. + $parser = new Parser(highlighter: null); + $input = "```\necho \"hi\";\n```"; + $this->assertSame( + '
    echo "hi";
    ', + $parser->parse($input)->html, + ); + } + + #[Test] + public function fenced_code_without_language_uses_txt_fallback_with_default_highlighter(): void + { + // Default highlighter's fallback is "txt"; non-php content passes through escaped by the highlighter. + $parser = new Parser(); + $input = "```\necho \"hi\";\n```"; + $this->assertSame( + '
    echo "hi";
    ', + $parser->parse($input)->html, + ); + } +} diff --git a/tests/QuoteTest.php b/tests/QuoteTest.php new file mode 100644 index 0000000..6eebeb4 --- /dev/null +++ b/tests/QuoteTest.php @@ -0,0 +1,87 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function single_line(): void + { + $this->assertSame( + '
    Hello
    ', + $this->parser->parse('> Hello')->html, + ); + } + + #[Test] + public function quote_with_bold(): void + { + $this->assertSame( + '
    Hello world
    ', + $this->parser->parse('> Hello **world**')->html, + ); + } + + #[Test] + public function quote_with_italic(): void + { + $this->assertSame( + '
    Hello world
    ', + $this->parser->parse('> Hello __world__')->html, + ); + } + + #[Test] + public function quote_with_link(): void + { + $this->assertSame( + '
    Hello world
    ', + $this->parser->parse('> Hello [world](#)')->html, + ); + } + + #[Test] + public function quote_with_image(): void + { + $this->assertSame( + '
    Hello world
    ', + $this->parser->parse('> Hello ![world](#)')->html, + ); + } + + #[Test] + public function nested_levels(): void + { + $input = <<<'MD' + > One + > > Two + > > > Three + > >>> Four + > > Two again + MD; + + $expected = <<<'HTML' +
    One +
    Two +
    Three +
    Four
    Two again
    + HTML; + + $this->assertSame($expected, $this->parser->parse($input)->html); + } +} diff --git a/tests/RulerTest.php b/tests/RulerTest.php new file mode 100644 index 0000000..b8dc66e --- /dev/null +++ b/tests/RulerTest.php @@ -0,0 +1,58 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function thin_ruler_three_dashes(): void + { + // `---` at byte 0 is front matter; we put a paragraph above so it's a ruler. + $this->assertSame( + "

    x\n


    ", + $this->parser->parse("x\n---")->html, + ); + } + + #[Test] + public function thin_ruler_long(): void + { + $this->assertSame( + "

    x\n


    ", + $this->parser->parse("x\n-----")->html, + ); + } + + #[Test] + public function thick_ruler_three_equals(): void + { + $this->assertSame( + '
    ', + $this->parser->parse('===')->html, + ); + } + + #[Test] + public function thick_ruler_long(): void + { + $this->assertSame( + '
    ', + $this->parser->parse('=====')->html, + ); + } +} diff --git a/tests/StrikethroughTest.php b/tests/StrikethroughTest.php new file mode 100644 index 0000000..27ad95c --- /dev/null +++ b/tests/StrikethroughTest.php @@ -0,0 +1,90 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function strike_inside_bold_double_tilde(): void + { + $this->assertSame( + '

    deleted

    ', + $this->parser->parse('**~~deleted~~**')->html, + ); + } + + #[Test] + public function strike_inside_bold_single_tilde(): void + { + $this->assertSame( + '

    deleted

    ', + $this->parser->parse('**~deleted~**')->html, + ); + } + + #[Test] + public function strike_inside_italic(): void + { + $this->assertSame( + '

    deleted

    ', + $this->parser->parse('__~~deleted~~__')->html, + ); + } + + #[Test] + public function strike_inside_link(): void + { + $this->assertSame( + '

    deleted

    ', + $this->parser->parse('[~~deleted~~](#)')->html, + ); + } + + #[Test] + public function strike_containing_italic(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('**~~hello __world__~~**')->html, + ); + } + + #[Test] + public function strike_containing_link(): void + { + $this->assertSame( + '

    hello world

    ', + $this->parser->parse('**~~hello [world](#)~~**')->html, + ); + } + + #[Test] + public function tildes_in_plain_paragraph_pass_through(): void + { + // No strike at top-level paragraph: tildes render as literal text. + $this->assertSame( + '

    ~~plain~~

    ', + $this->parser->parse('~~plain~~')->html, + ); + } +} diff --git a/tests/TableTest.php b/tests/TableTest.php new file mode 100644 index 0000000..289f041 --- /dev/null +++ b/tests/TableTest.php @@ -0,0 +1,68 @@ +parser = new Parser(highlighter: null); + } + + #[Test] + public function header_only(): void + { + $this->assertSame( + '
    AB
    ', + $this->parser->parse("| A | B |\n| --- | --- |")->html, + ); + } + + #[Test] + public function full_table(): void + { + $this->assertSame( + '
    AB
    12
    ', + $this->parser->parse("| A | B |\n| --- | --- |\n| 1 | 2 |")->html, + ); + } + + #[Test] + public function multiple_data_rows(): void + { + $this->assertSame( + '
    AB
    12
    34
    ', + $this->parser->parse("| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |")->html, + ); + } + + #[Test] + public function separator_with_alignment_is_filtered(): void + { + // Separator rows with leading/trailing `:` (alignment markers) are still detected + // and not emitted as data rows. + $this->assertSame( + '
    ABC
    123
    ', + $this->parser->parse("| A | B | C |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |")->html, + ); + } + + #[Test] + public function inline_formatting_in_cells(): void + { + $this->assertSame( + '
    NameNotes
    Alicecode
    ', + $this->parser->parse("| Name | Notes |\n| --- | --- |\n| **Alice** | `code` |")->html, + ); + } +} diff --git a/tests/Tokens/BoldTokenTest.php b/tests/Tokens/BoldTokenTest.php deleted file mode 100644 index b3d6079..0000000 --- a/tests/Tokens/BoldTokenTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertEquals('world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_italic_text(): void - { - $token = new BoldToken('hello __world__'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_strikethrough_text(): void - { - $token = new BoldToken('hello ~~world~~'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_link(): void - { - $token = new BoldToken('hello [world](#)'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/CodeTokenTest.php b/tests/Tokens/CodeTokenTest.php deleted file mode 100644 index 1936480..0000000 --- a/tests/Tokens/CodeTokenTest.php +++ /dev/null @@ -1,35 +0,0 @@ -assertEquals('$foo', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_language(): void - { - $token = new CodeToken('php', 'echo "hi";'); - - $this->assertEquals('echo "hi";', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_language_without_highlighter(): void - { - $token = new CodeToken('php', 'echo "hi";'); - - $this->assertEquals('echo "hi";', $token->parse(new Parser(highlighter: null))); - } -} diff --git a/tests/Tokens/DivTokenTest.php b/tests/Tokens/DivTokenTest.php deleted file mode 100644 index c13c85f..0000000 --- a/tests/Tokens/DivTokenTest.php +++ /dev/null @@ -1,59 +0,0 @@ -assertEquals('
    Hello
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_class(): void - { - $token = new DivToken(class: 'warning', content: 'Hello'); - - $this->assertEquals('
    Hello
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_multiple_classes(): void - { - $token = new DivToken(class: 'foo bar', content: 'Hello'); - - $this->assertEquals('
    Hello
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_bold(): void - { - $token = new DivToken(class: null, content: 'Hello **world**'); - - $this->assertEquals('
    Hello world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_italic(): void - { - $token = new DivToken(class: null, content: 'Hello __world__'); - - $this->assertEquals('
    Hello world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_link(): void - { - $token = new DivToken(class: null, content: 'Hello [world](#)'); - - $this->assertEquals('
    Hello world
    ', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/FrontMatterTokenTest.php b/tests/Tokens/FrontMatterTokenTest.php deleted file mode 100644 index 51d3a4b..0000000 --- a/tests/Tokens/FrontMatterTokenTest.php +++ /dev/null @@ -1,19 +0,0 @@ - 'bar']); - - $this->assertEquals('', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/HeadingTokenTest.php b/tests/Tokens/HeadingTokenTest.php deleted file mode 100644 index f874bea..0000000 --- a/tests/Tokens/HeadingTokenTest.php +++ /dev/null @@ -1,35 +0,0 @@ -assertEquals('

    Hello

    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_h2(): void - { - $token = new HeadingToken('Hello', 2); - - $this->assertEquals('

    Hello

    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_h6(): void - { - $token = new HeadingToken('Hello World', 6); - - $this->assertEquals('
    Hello World
    ', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/ImageTokenTest.php b/tests/Tokens/ImageTokenTest.php deleted file mode 100644 index 0f3bcc6..0000000 --- a/tests/Tokens/ImageTokenTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertEquals('a cat', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_without_alt(): void - { - $token = new ImageToken('https://example.com/img.png', null); - - $this->assertEquals('', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/ItalicTokenTest.php b/tests/Tokens/ItalicTokenTest.php deleted file mode 100644 index 41e4bc3..0000000 --- a/tests/Tokens/ItalicTokenTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertEquals('world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_bold_text(): void - { - $token = new ItalicToken('hello **world**'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_strikethrough_text(): void - { - $token = new ItalicToken('hello ~~world~~'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_link(): void - { - $token = new ItalicToken('hello [world](#)'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/LinkTokenTest.php b/tests/Tokens/LinkTokenTest.php deleted file mode 100644 index 9c5d57d..0000000 --- a/tests/Tokens/LinkTokenTest.php +++ /dev/null @@ -1,51 +0,0 @@ -assertEquals('click here', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_bold_text(): void - { - $token = new LinkToken('click **here**', '#'); - - $this->assertEquals('click here', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_italic_text(): void - { - $token = new LinkToken('click __here__', '#'); - - $this->assertEquals('click here', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_strikethrough_text(): void - { - $token = new LinkToken('click ~~here~~', '#'); - - $this->assertEquals('click here', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_target_blank_text(): void - { - $token = new LinkToken('click here', '*https://tempestphp.com'); - - $this->assertEquals('click here', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/ListTokenTest.php b/tests/Tokens/ListTokenTest.php deleted file mode 100644 index cfd11d2..0000000 --- a/tests/Tokens/ListTokenTest.php +++ /dev/null @@ -1,98 +0,0 @@ -assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_multiple_items(): void - { - $token = new ListToken([new ListItem('one'), new ListItem('two'), new ListItem('three')]); - - $this->assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_bold(): void - { - $token = new ListToken([new ListItem('hello **world**')]); - - $this->assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_italic(): void - { - $token = new ListToken([new ListItem('hello __world__')]); - - $this->assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_link(): void - { - $token = new ListToken([new ListItem('[world](#)')]); - - $this->assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_code(): void - { - $token = new ListToken([new ListItem('run `php tempest`')]); - - $this->assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_nested(): void - { - $token = new ListToken([ - new ListItem('parent', new ListToken([ - new ListItem('child'), - ])), - ]); - - $this->assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_nested_multiple_children(): void - { - $token = new ListToken([ - new ListItem('parent', new ListToken([ - new ListItem('child one'), - new ListItem('child two'), - ])), - ]); - - $this->assertEquals('', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_nested_sibling_after_sublist(): void - { - $token = new ListToken([ - new ListItem('one', new ListToken([ - new ListItem('child'), - ])), - new ListItem('two'), - ]); - - $this->assertEquals('', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/NewLineTokenTest.php b/tests/Tokens/NewLineTokenTest.php deleted file mode 100644 index 9b05027..0000000 --- a/tests/Tokens/NewLineTokenTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertEquals("\n\n", $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/OrderedListTokenTest.php b/tests/Tokens/OrderedListTokenTest.php deleted file mode 100644 index 99e0f6f..0000000 --- a/tests/Tokens/OrderedListTokenTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertEquals('
    1. item
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_multiple_items(): void - { - $token = new OrderedListToken([new ListItem('one'), new ListItem('two'), new ListItem('three')]); - - $this->assertEquals('
    1. one
    2. two
    3. three
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_bold(): void - { - $token = new OrderedListToken([new ListItem('hello **world**')]); - - $this->assertEquals('
    1. hello world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_italic(): void - { - $token = new OrderedListToken([new ListItem('hello __world__')]); - - $this->assertEquals('
    1. hello world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_link(): void - { - $token = new OrderedListToken([new ListItem('[world](#)')]); - - $this->assertEquals('
    1. world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_code(): void - { - $token = new OrderedListToken([new ListItem('run `php tempest`')]); - - $this->assertEquals('
    1. run php tempest
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_nested(): void - { - $token = new OrderedListToken([ - new ListItem('parent', new OrderedListToken([ - new ListItem('child'), - ])), - ]); - - $this->assertEquals('
    1. parent
      1. child
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_nested_sibling_after_sublist(): void - { - $token = new OrderedListToken([ - new ListItem('one', new OrderedListToken([ - new ListItem('child'), - ])), - new ListItem('two'), - ]); - - $this->assertEquals('
    1. one
      1. child
    2. two
    ', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/ParagraphTokenTest.php b/tests/Tokens/ParagraphTokenTest.php deleted file mode 100644 index 03b80e2..0000000 --- a/tests/Tokens/ParagraphTokenTest.php +++ /dev/null @@ -1,71 +0,0 @@ -Hello, world!

    '; - - $actualHtml = $token->parse(new Parser()); - - $this->assertEquals($expectedHtml, $actualHtml); - } - - #[Test] - public function test_parse_with_italic(): void - { - $token = new ParagraphToken('Hello, __world__!'); - - $expectedHtml = '

    Hello, world!

    '; - - $actualHtml = $token->parse(new Parser()); - - $this->assertEquals($expectedHtml, $actualHtml); - } - - #[Test] - public function test_parse_with_link(): void - { - $token = new ParagraphToken('Hello, [world](#)!'); - - $expectedHtml = '

    Hello, world!

    '; - - $actualHtml = $token->parse(new Parser()); - - $this->assertEquals($expectedHtml, $actualHtml); - } - - #[Test] - public function test_parse_with_image(): void - { - $token = new ParagraphToken('Hello, ![world](#)!'); - - $expectedHtml = '

    Hello, world!

    '; - - $actualHtml = $token->parse(new Parser()); - - $this->assertEquals($expectedHtml, $actualHtml); - } - - #[Test] - public function test_parse_with_code(): void - { - $token = new ParagraphToken('Hello, `world`!'); - - $expectedHtml = '

    Hello, world!

    '; - - $actualHtml = $token->parse(new Parser()); - - $this->assertEquals($expectedHtml, $actualHtml); - } -} diff --git a/tests/Tokens/PreTokenTest.php b/tests/Tokens/PreTokenTest.php deleted file mode 100644 index ba3d1e0..0000000 --- a/tests/Tokens/PreTokenTest.php +++ /dev/null @@ -1,44 +0,0 @@ -assertEquals( - '
    echo "hi";
    ', - $token->parse(new Parser()), - ); - } - - #[Test] - public function test_parse_without_language(): void - { - $token = new PreToken(language: null, content: 'echo "hi";'); - - $this->assertEquals( - '
    echo "hi";
    ', - $token->parse(new Parser()), - ); - } - - #[Test] - public function test_parse_without_highlighter(): void - { - $token = new PreToken(language: null, content: 'echo "hi";'); - - $this->assertEquals( - '
    echo "hi";
    ', - $token->parse(new Parser(highlighter: null)), - ); - } -} diff --git a/tests/Tokens/QuoteTokenTest.php b/tests/Tokens/QuoteTokenTest.php deleted file mode 100644 index 4108a05..0000000 --- a/tests/Tokens/QuoteTokenTest.php +++ /dev/null @@ -1,74 +0,0 @@ -assertEquals('
    Hello
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_multiple_levels(): void - { - $token = new QuoteToken(<<<'TXT' - One - > Two - > > Three - >>> Four - > Two again - TXT); - - $parsed = $token->parse(new Parser()); - - $expected = <<<'HTML' -
    One -
    Two -
    Three -
    Four
    Two again
    - HTML; - - $this->assertEquals($expected, $parsed); - } - - #[Test] - public function test_bold_text(): void - { - $token = new QuoteToken('Hello **world**'); - - $this->assertEquals('
    Hello world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_italic_text(): void - { - $token = new QuoteToken('Hello __world__'); - - $this->assertEquals('
    Hello world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_link(): void - { - $token = new QuoteToken('Hello [world](#)'); - - $this->assertEquals('
    Hello world
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_image(): void - { - $token = new QuoteToken('Hello ![world](#)'); - - $this->assertEquals('
    Hello world
    ', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/RulerTokenTest.php b/tests/Tokens/RulerTokenTest.php deleted file mode 100644 index 2014588..0000000 --- a/tests/Tokens/RulerTokenTest.php +++ /dev/null @@ -1,28 +0,0 @@ -assertEquals('
    ', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_thick(): void - { - $token = new RulerToken('===', RulerType::THICK); - - $this->assertEquals('
    ', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/StrikethroughTokenTest.php b/tests/Tokens/StrikethroughTokenTest.php deleted file mode 100644 index e3a5e3d..0000000 --- a/tests/Tokens/StrikethroughTokenTest.php +++ /dev/null @@ -1,43 +0,0 @@ -assertEquals('deleted', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_italic_text(): void - { - $token = new StrikethroughToken('hello __world__'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_bold_text(): void - { - $token = new StrikethroughToken('hello **world**'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } - - #[Test] - public function test_parse_with_link(): void - { - $token = new StrikethroughToken('hello [world](#)'); - - $this->assertEquals('hello world', $token->parse(new Parser())); - } -} diff --git a/tests/Tokens/TableTokenTest.php b/tests/Tokens/TableTokenTest.php deleted file mode 100644 index ecfdab5..0000000 --- a/tests/Tokens/TableTokenTest.php +++ /dev/null @@ -1,81 +0,0 @@ -assertEquals( - '
    NameAge
    Alice30
    ', - $token->parse(new Parser()), - ); - } - - #[Test] - public function test_parse_header_only(): void - { - $token = new TableToken([ - new TableRow(['A', 'B'], isHeader: true), - ]); - - $this->assertEquals( - '
    AB
    ', - $token->parse(new Parser()), - ); - } - - #[Test] - public function test_parse_with_inline_formatting(): void - { - $token = new TableToken([ - new TableRow(['Name', 'Notes'], isHeader: true), - new TableRow(['**Alice**', '`code`'], isHeader: false), - ]); - - $this->assertEquals( - '
    NameNotes
    Alicecode
    ', - $token->parse(new Parser()), - ); - } - - #[Test] - public function test_parse_with_image(): void - { - $token = new TableToken([ - new TableRow(['![image](#)', '`code`'], isHeader: false), - ]); - - $this->assertEquals( - '
    imagecode
    ', - $token->parse(new Parser()), - ); - } - - #[Test] - public function test_parse_multiple_rows(): void - { - $token = new TableToken([ - new TableRow(['A', 'B'], isHeader: true), - new TableRow(['1', '2'], isHeader: false), - new TableRow(['3', '4'], isHeader: false), - ]); - - $this->assertEquals( - '
    AB
    12
    34
    ', - $token->parse(new Parser()), - ); - } -} diff --git a/tests/Tokens/TextTokenTest.php b/tests/Tokens/TextTokenTest.php deleted file mode 100644 index 2757b02..0000000 --- a/tests/Tokens/TextTokenTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertEquals('Hello, world!', $token->parse(new Parser())); - } -}