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: "
'; + 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::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 .= "
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 .= "{$tag}>";
+
+ 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}{$tag}>";
- }
-}
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 = '';
-
- foreach ($this->items as $item) {
- $content = $parser->parse($item->content);
- $children = $item->children?->parse($parser) ?? '';
- $list .= "- {$content}{$children}
";
- }
-
- $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 .= "- {$content}{$children}
";
- }
-
- $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 .= '' . $parser->parse($cell)->html . ' ';
- }
-
- $table .= ' ';
- }
-
- $table .= '';
- }
-
- if ($dataRows !== []) {
- $table .= '';
-
- foreach ($dataRows as $row) {
- $table .= '';
-
- foreach ($row->cells as $cell) {
- $table .= '' . $parser->parse($cell)->html . ' ';
- }
-
- $table .= ' ';
- }
-
- $table .= '';
- }
-
- $table .= '
';
-
- 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(
+ '
',
+ $this->parser->parse('')->html,
+ );
+ }
+
+ #[Test]
+ public function image_without_alt(): void
+ {
+ $this->assertSame(
+ '
',
+ $this->parser->parse('')->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('')[0];
-
- $this->assertEquals(new ImageToken('href', 'alt'), $token);
- }
-
- #[Test]
- public function test_lex_without_alt(): void
- {
- $token = new Lexer([new ImageRule()])->lex('')[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(
+ '',
+ $this->parser->parse('[click here](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function link_without_href(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click here]')->html,
+ );
+ }
+
+ #[Test]
+ public function link_containing_bold(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click **here**](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function link_containing_italic(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click __here__](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function link_containing_strikethrough(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click ~~here~~](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function asterisk_prefix_opens_new_tab(): void
+ {
+ $this->assertSame(
+ '',
+ $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(
+ '- item
',
+ $this->parser->parse('- item')->html,
+ );
+ }
+
+ #[Test]
+ public function multiple_items(): void
+ {
+ $this->assertSame(
+ '- one
- two
- three
',
+ $this->parser->parse("- one\n- two\n- three")->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_bold(): void
+ {
+ $this->assertSame(
+ '- hello world
',
+ $this->parser->parse('- hello **world**')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_italic(): void
+ {
+ $this->assertSame(
+ '- hello world
',
+ $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(
+ '- run
php tempest
',
+ $this->parser->parse('- run `php tempest`')->html,
+ );
+ }
+
+ #[Test]
+ public function nested_single_child(): void
+ {
+ $this->assertSame(
+ '- parent
- child
',
+ $this->parser->parse("- parent\n - child")->html,
+ );
+ }
+
+ #[Test]
+ public function nested_multiple_children(): void
+ {
+ $this->assertSame(
+ '- parent
- child one
- child two
',
+ $this->parser->parse("- parent\n - child one\n - child two")->html,
+ );
+ }
+
+ #[Test]
+ public function nested_sibling_after_sublist(): void
+ {
+ $this->assertSame(
+ '- one
- child
- two
',
+ $this->parser->parse("- one\n - child\n- two")->html,
+ );
+ }
+
+ #[Test]
+ public function three_levels_of_nesting(): void
+ {
+ $this->assertSame(
+ '- a
- b
- c
',
+ $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(
+ '- item
',
+ $this->parser->parse('1. item')->html,
+ );
+ }
+
+ #[Test]
+ public function multiple_items(): void
+ {
+ $this->assertSame(
+ '- one
- two
- three
',
+ $this->parser->parse("1. one\n2. two\n3. three")->html,
+ );
+ }
+
+ #[Test]
+ public function multi_digit_marker(): void
+ {
+ $this->assertSame(
+ '- tenth
',
+ $this->parser->parse('10. tenth')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_bold(): void
+ {
+ $this->assertSame(
+ '- hello world
',
+ $this->parser->parse('1. hello **world**')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_link(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('1. [world](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function nested(): void
+ {
+ $this->assertSame(
+ '- parent
- child
',
+ $this->parser->parse("1. parent\n 2. child")->html,
+ );
+ }
+
+ #[Test]
+ public function nested_sibling_after_sublist(): void
+ {
+ $this->assertSame(
+ '- one
- child
- 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,
!
',
+ $this->parser->parse('Hello, !')->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 
',
+ $this->parser->parse('> Hello ')->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(
+ '',
+ $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(
+ 'A B
',
+ $this->parser->parse("| A | B |\n| --- | --- |")->html,
+ );
+ }
+
+ #[Test]
+ public function full_table(): void
+ {
+ $this->assertSame(
+ 'A B 1 2
',
+ $this->parser->parse("| A | B |\n| --- | --- |\n| 1 | 2 |")->html,
+ );
+ }
+
+ #[Test]
+ public function multiple_data_rows(): void
+ {
+ $this->assertSame(
+ 'A B 1 2 3 4
',
+ $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(
+ 'A B C 1 2 3
',
+ $this->parser->parse("| A | B | C |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |")->html,
+ );
+ }
+
+ #[Test]
+ public function inline_formatting_in_cells(): void
+ {
+ $this->assertSame(
+ 'Name Notes Alice code
',
+ $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('', $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('
', $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('- item
', $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('- one
- two
- three
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold(): void
- {
- $token = new ListToken([new ListItem('hello **world**')]);
-
- $this->assertEquals('- hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic(): void
- {
- $token = new ListToken([new ListItem('hello __world__')]);
-
- $this->assertEquals('- hello world
', $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('- run
php tempest
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_nested(): void
- {
- $token = new ListToken([
- new ListItem('parent', new ListToken([
- new ListItem('child'),
- ])),
- ]);
-
- $this->assertEquals('- parent
- child
', $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('- parent
- child one
- child two
', $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('- one
- child
- two
', $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('- 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('- one
- two
- three
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold(): void
- {
- $token = new OrderedListToken([new ListItem('hello **world**')]);
-
- $this->assertEquals('- hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic(): void
- {
- $token = new OrderedListToken([new ListItem('hello __world__')]);
-
- $this->assertEquals('- hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new OrderedListToken([new ListItem('[world](#)')]);
-
- $this->assertEquals('', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_code(): void
- {
- $token = new OrderedListToken([new ListItem('run `php tempest`')]);
-
- $this->assertEquals('- 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('- parent
- 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('- one
- child
- 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, !');
-
- $expectedHtml = 'Hello,
!
';
-
- $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 ');
-
- $this->assertEquals('Hello 
', $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(
- 'Name Age Alice 30
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_header_only(): void
- {
- $token = new TableToken([
- new TableRow(['A', 'B'], isHeader: true),
- ]);
-
- $this->assertEquals(
- 'A B
',
- $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(
- 'Name Notes Alice code
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_with_image(): void
- {
- $token = new TableToken([
- new TableRow(['', '`code`'], isHeader: false),
- ]);
-
- $this->assertEquals(
- '
code
',
- $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(
- 'A B 1 2 3 4
',
- $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()));
- }
-}