Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -667,10 +667,12 @@ protected function parseBlocks(Node $parent, array $lines, int $indent): void
}

// Try to match block elements in order of precedence
// Line comment (%%) must come before fenced comment (%%%)
// Fenced comment must come before thematic break (%%% vs ---)
// Comment and raw block must come before code block since ``` =format is a special case
// Caption must come before paragraph to catch `^ caption text`
$consumed = $this->tryParseFencedComment($parent, $lines, $i)
$consumed = $this->tryParseLineComment($lines, $i)
?? $this->tryParseFencedComment($parent, $lines, $i)
?? $this->tryParseComment($parent, $lines, $i)
?? $this->tryParseRawBlock($parent, $lines, $i)
?? $this->tryParseCodeBlock($parent, $lines, $i)
Expand Down Expand Up @@ -889,6 +891,34 @@ protected function tryParseCodeBlock(Node $parent, array $lines, int $start): ?i
return $i - $start;
}

/**
* Try to parse a line comment (%% to end of line)
*
* A line starting with %% (after optional whitespace) is a full-line comment.
* It is completely ignored without creating any nodes.
*
* @param array<string> $lines
* @param int $start
*/
protected function tryParseLineComment(array $lines, int $start): ?int
{
$line = $lines[$start];
$trimmed = ltrim($line);

// Check if line starts with %%
if (!str_starts_with($trimmed, '%%')) {
return null;
}

// Make sure it's not %%% (fenced comment opener)
if (str_starts_with($trimmed, '%%%')) {
return null;
}

// Line comment - consume the line without creating any node
return 1;
}

/**
* Try to parse a comment block {% ... %}
*
Expand Down
241 changes: 241 additions & 0 deletions src/Parser/InlineParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ public function parse(Node $parent, string $text, int $sourceLine = 0): void
{
$this->delimiterStack = [];
$this->currentLine = $sourceLine;
// Strip line comments (%% to end of line) before parsing
$text = $this->removeLineComments($text);
$this->parseInlines($parent, $text);
}

Expand Down Expand Up @@ -1706,6 +1708,245 @@ protected function removeAttributeComments(string $attrStr): string
return $result ?? $attrStr;
}

/**
* Remove line comments from text: %% to end of line
*
* Line comments start with %% and extend to the end of the line.
* They are stripped before inline parsing, but only when not inside:
* - Code spans (backticks)
* - Quoted strings (in attributes or link titles)
* - Link/image destinations
*/
protected function removeLineComments(string $text): string
{
$lines = explode("\n", $text);
$result = [];

foreach ($lines as $line) {
$result[] = $this->stripLineComment($line);
}

return implode("\n", $result);
}

/**
* Strip %% comment from a single line, respecting context
*/
protected function stripLineComment(string $line): string
{
$length = strlen($line);
$pos = 0;

while ($pos < $length - 1) {
$char = $line[$pos];
$nextChar = $line[$pos + 1];

// Check for escape sequence
if ($char === '\\' && $pos + 1 < $length) {
$pos += 2; // Skip escaped character

continue;
}

// Check for %% (line comment) - but not %%%
if ($char === '%' && $nextChar === '%') {
// Make sure it's not %%% (fenced comment marker)
if ($pos + 2 < $length && $line[$pos + 2] === '%') {
$pos++;

continue;
}

// Found line comment - strip rest of line
return rtrim(substr($line, 0, $pos));
}

// Skip backtick spans (code)
if ($char === '`') {
$pos = $this->skipBacktickSpan($line, $pos, $length);

continue;
}

// Skip dollar signs (math) - $...$ or $$...$$
if ($char === '$') {
$pos = $this->skipMathSpan($line, $pos, $length);

continue;
}

// Skip parenthesized content (link destinations/titles)
if ($char === '(') {
$pos = $this->skipParenthesized($line, $pos, $length);

continue;
}

// Skip curly braces (attributes) - handle quoted values inside
if ($char === '{') {
$pos = $this->skipAttributeBlock($line, $pos, $length);

continue;
}

$pos++;
}

return $line;
}

/**
* Skip a backtick span (code), returning position after closing backticks
*/
protected function skipBacktickSpan(string $line, int $start, int $length): int
{
// Count opening backticks
$backtickCount = 0;
$pos = $start;
while ($pos < $length && $line[$pos] === '`') {
$backtickCount++;
$pos++;
}

// Find matching closing backticks
while ($pos <= $length - $backtickCount) {
if (substr($line, $pos, $backtickCount) === str_repeat('`', $backtickCount)) {
// Check it's exactly this many backticks (not more)
$afterBackticks = $pos + $backtickCount;
if ($afterBackticks >= $length || $line[$afterBackticks] !== '`') {
return $afterBackticks;
}
}
$pos++;
}

// No closing found, return end
return $length;
}

/**
* Skip a math span ($...$ or $$...$$), returning position after closing
*/
protected function skipMathSpan(string $line, int $start, int $length): int
{
$pos = $start;
$isDisplay = ($pos + 1 < $length && $line[$pos + 1] === '$');
$delimiter = $isDisplay ? '$$' : '$';
$delimLen = strlen($delimiter);

$pos += $delimLen; // Skip opening delimiter

// Find closing delimiter
while ($pos <= $length - $delimLen) {
if ($line[$pos] === '\\' && $pos + 1 < $length) {
$pos += 2; // Skip escaped character

continue;
}
if (substr($line, $pos, $delimLen) === $delimiter) {
return $pos + $delimLen;
}
$pos++;
}

return $length;
}

/**
* Skip parenthesized content (link destination/title), returning position after )
*/
protected function skipParenthesized(string $line, int $start, int $length): int
{
$pos = $start + 1; // Skip opening (
$depth = 1;

while ($pos < $length && $depth > 0) {
$char = $line[$pos];

if ($char === '\\' && $pos + 1 < $length) {
$pos += 2; // Skip escaped character

continue;
}

// Handle quoted strings inside parentheses (link titles)
if ($char === '"' || $char === "'") {
$pos = $this->skipQuotedString($line, $pos, $length, $char);

continue;
}

if ($char === '(') {
$depth++;
} elseif ($char === ')') {
$depth--;
}
$pos++;
}

return $pos;
}

/**
* Skip an attribute block {...}, returning position after }
*/
protected function skipAttributeBlock(string $line, int $start, int $length): int
{
$pos = $start + 1; // Skip opening {
$depth = 1;

while ($pos < $length && $depth > 0) {
$char = $line[$pos];

if ($char === '\\' && $pos + 1 < $length) {
$pos += 2; // Skip escaped character

continue;
}

// Handle quoted attribute values
if ($char === '"' || $char === "'") {
$pos = $this->skipQuotedString($line, $pos, $length, $char);

continue;
}

if ($char === '{') {
$depth++;
} elseif ($char === '}') {
$depth--;
}
$pos++;
}

return $pos;
}

/**
* Skip a quoted string, returning position after closing quote
*/
protected function skipQuotedString(string $line, int $start, int $length, string $quote): int
{
$pos = $start + 1; // Skip opening quote

while ($pos < $length) {
$char = $line[$pos];

if ($char === '\\' && $pos + 1 < $length) {
$pos += 2; // Skip escaped character

continue;
}

if ($char === $quote) {
return $pos + 1;
}
$pos++;
}

return $length;
}

/**
* Apply attributes from a string to a node
*/
Expand Down
Loading
Loading