Skip to content

Commit f5f043b

Browse files
authored
Add trailing attribute support for inline elements (#78)
Implements trailing attributes for emphasis, strong, code spans, superscript, subscript, insert, delete, highlight, and symbol elements. Syntax examples: - `_text_{.highlight}` → `<em class="highlight">` - `*text*{.important}` → `<strong class="important">` - `` `code`{.lang-js} `` → `<code class="lang-js">` - `{=text=}{.match}` → `<mark class="match">` - `:emoji:{.large}` → symbol with class This aligns with the official djot specification and brings parity with links, images, and spans which already support trailing attributes. Closes #74
1 parent 7519524 commit f5f043b

File tree

3 files changed

+250
-34
lines changed

3 files changed

+250
-34
lines changed

docs/_attribute-support.md

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This document details attribute support for Djot elements in djot-php.
44

5-
**Status:** Block elements and sub-elements have full attribute support. Some inline elements (emphasis, strong, code spans, etc.) do not yet support trailing attributes.
5+
**Status:** Full attribute support for all block elements, sub-elements, and inline elements.
66

77
---
88

@@ -44,22 +44,20 @@ All block-level elements receive attributes from a standalone `{...}` on the pre
4444

4545
Inline elements receive attributes immediately following the closing delimiter.
4646

47-
| Element | Syntax | HTML Output | Status |
48-
|---------|--------|-------------|--------|
49-
| Link | `[text](url){.external}` | `<a href="url" class="external">` ||
50-
| Image | `![alt](src){.photo}` | `<img src="src" class="photo">` ||
51-
| Span | `[text]{.note}` | `<span class="note">` ||
52-
| Emphasis | `_text_{.highlight}` | `<em class="highlight">` | No |
53-
| Strong | `*text*{.important}` | `<strong class="important">` | No |
54-
| Code Span | `` `code`{.lang-js} `` | `<code class="lang-js">` | No |
55-
| Superscript | `{^text^}{.ref}` | `<sup class="ref">` | No |
56-
| Subscript | `{~text~}{.chemical}` | `<sub class="chemical">` | No |
57-
| Insert | `{+text+}{.added}` | `<ins class="added">` | No |
58-
| Delete | `{-text-}{.removed}` | `<del class="removed">` | No |
59-
| Highlight | `{=text=}{.match}` | `<mark class="match">` | No |
60-
| Symbol | `:emoji:{.large}` | `<span class="symbol large">` | No |
61-
62-
If needed, one can always wrap with a "span": `[...]{...}`.
47+
| Element | Syntax | HTML Output |
48+
|---------|--------|-------------|
49+
| Link | `[text](url){.external}` | `<a href="url" class="external">` |
50+
| Image | `![alt](src){.photo}` | `<img src="src" class="photo">` |
51+
| Span | `[text]{.note}` | `<span class="note">` |
52+
| Emphasis | `_text_{.highlight}` | `<em class="highlight">` |
53+
| Strong | `*text*{.important}` | `<strong class="important">` |
54+
| Code Span | `` `code`{.lang-js} `` | `<code class="lang-js">` |
55+
| Superscript | `^text^{.ref}` or `{^text^}{.ref}` | `<sup class="ref">` |
56+
| Subscript | `~text~{.chemical}` or `{~text~}{.chemical}` | `<sub class="chemical">` |
57+
| Insert | `{+text+}{.added}` | `<ins class="added">` |
58+
| Delete | `{-text-}{.removed}` | `<del class="removed">` |
59+
| Highlight | `{=text=}{.match}` | `<mark class="match">` |
60+
| Symbol | `:emoji:{.large}` | `<span class="symbol large">` |
6361

6462
---
6563

@@ -212,15 +210,15 @@ The general rule follows Djot conventions:
212210
| Link | Yes | Suffix | `[](url){}` |
213211
| Image | Yes | Suffix | `![](url){}` |
214212
| Span | Yes | Suffix | `[text]{}` |
215-
| Emphasis | No | Suffix | `_..._{}` |
216-
| Strong | No | Suffix | `*...*{}` |
217-
| CodeSpan | No | Suffix | `` `...`{} `` |
218-
| Superscript | No | Suffix | `{^...^}{}` |
219-
| Subscript | No | Suffix | `{~...~}{}` |
220-
| Insert | No | Suffix | `{+...+}{}` |
221-
| Delete | No | Suffix | `{-...-}{}` |
222-
| Highlight | No | Suffix | `{=...=}{}` |
223-
| Symbol | No | Suffix | `:name:{}` |
213+
| Emphasis | Yes | Suffix | `_..._{}` |
214+
| Strong | Yes | Suffix | `*...*{}` |
215+
| CodeSpan | Yes | Suffix | `` `...`{} `` |
216+
| Superscript | Yes | Suffix | `^...^{}` or `{^...^}{}` |
217+
| Subscript | Yes | Suffix | `~...~{}` or `{~...~}{}` |
218+
| Insert | Yes | Suffix | `{+...+}{}` |
219+
| Delete | Yes | Suffix | `{-...-}{}` |
220+
| Highlight | Yes | Suffix | `{=...=}{}` |
221+
| Symbol | Yes | Suffix | `:name:{}` |
224222
| FootnoteRef | No | Suffix | `[^ref]{}` |
225223
| **Special** ||||
226224
| Comment | No | N/A | Not rendered |

src/Parser/InlineParser.php

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,9 @@ protected function parseCodeSpan(string $text, int $pos): ?array
652652
// Check for raw inline format: `...`{=format}
653653
// Format must be ONLY {=format} with no other attributes
654654
$endPos = $afterClose;
655-
$isRawInline = $afterClose < $length && $text[$afterClose] === '{'
655+
$hasRawInlineAttempt = $afterClose < $length && $text[$afterClose] === '{'
656656
&& $afterClose + 1 < $length && $text[$afterClose + 1] === '=';
657-
if ($isRawInline) {
657+
if ($hasRawInlineAttempt) {
658658
$formatEnd = strpos($text, '}', $afterClose);
659659
if ($formatEnd !== false) {
660660
$format = substr($text, $afterClose + 2, $formatEnd - $afterClose - 2);
@@ -667,12 +667,26 @@ protected function parseCodeSpan(string $text, int $pos): ?array
667667
'pos' => $endPos,
668668
];
669669
}
670-
// Mixed attributes like {=html #id} - treat attribute block as literal
670+
// Mixed attributes like {=html #id} - treat attribute block as literal text
671+
// Don't parse as trailing attributes either
672+
}
673+
}
674+
675+
$code = new Code($content);
676+
677+
// Check for trailing attributes: `code`{.class}
678+
// But NOT if there was a {= pattern (failed raw inline attempt should be literal)
679+
if (!$hasRawInlineAttempt && $endPos < $length && $text[$endPos] === '{') {
680+
$attrEnd = $this->findAttributeEnd($text, $endPos);
681+
if ($attrEnd !== null) {
682+
$attrStr = substr($text, $endPos + 1, $attrEnd - $endPos - 1);
683+
$this->applyAttributesToNode($code, $attrStr);
684+
$endPos = $attrEnd + 1;
671685
}
672686
}
673687

674688
return [
675-
'node' => new Code($content),
689+
'node' => $code,
676690
'pos' => $endPos,
677691
];
678692
}
@@ -1074,9 +1088,21 @@ protected function parseDelimited(string $text, int $pos, string $delimiter, str
10741088
$node = new $nodeClass();
10751089
$this->parseInlines($node, $content);
10761090

1091+
$endPos = $actualClose + 1;
1092+
1093+
// Check for trailing attributes: _text_{.class}
1094+
if ($endPos < $length && $text[$endPos] === '{') {
1095+
$attrEnd = $this->findAttributeEnd($text, $endPos);
1096+
if ($attrEnd !== null) {
1097+
$attrStr = substr($text, $endPos + 1, $attrEnd - $endPos - 1);
1098+
$this->applyAttributesToNode($node, $attrStr);
1099+
$endPos = $attrEnd + 1;
1100+
}
1101+
}
1102+
10771103
return [
10781104
'node' => $node,
1079-
'pos' => $actualClose + 1,
1105+
'pos' => $endPos,
10801106
];
10811107
}
10821108
}
@@ -1164,9 +1190,21 @@ protected function parseBracedInline(string $text, int $pos): ?array
11641190
$node = new $nodeClass();
11651191
$this->parseInlines($node, $content);
11661192

1193+
$endPos = $searchPos + 2;
1194+
1195+
// Check for trailing attributes: {=text=}{.class}
1196+
if ($endPos < $length && $text[$endPos] === '{') {
1197+
$attrEnd = $this->findAttributeEnd($text, $endPos);
1198+
if ($attrEnd !== null) {
1199+
$attrStr = substr($text, $endPos + 1, $attrEnd - $endPos - 1);
1200+
$this->applyAttributesToNode($node, $attrStr);
1201+
$endPos = $attrEnd + 1;
1202+
}
1203+
}
1204+
11671205
return [
11681206
'node' => $node,
1169-
'pos' => $searchPos + 2,
1207+
'pos' => $endPos,
11701208
];
11711209
}
11721210
$searchPos++;
@@ -1727,9 +1765,23 @@ protected function parseSymbol(string $text, int $pos): ?array
17271765
return null;
17281766
}
17291767

1768+
$symbol = new Symbol($matches[1]);
1769+
$endPos = $pos + strlen($matches[0]);
1770+
$length = strlen($text);
1771+
1772+
// Check for trailing attributes: :symbol:{.class}
1773+
if ($endPos < $length && $text[$endPos] === '{') {
1774+
$attrEnd = $this->findAttributeEnd($text, $endPos);
1775+
if ($attrEnd !== null) {
1776+
$attrStr = substr($text, $endPos + 1, $attrEnd - $endPos - 1);
1777+
$this->applyAttributesToNode($symbol, $attrStr);
1778+
$endPos = $attrEnd + 1;
1779+
}
1780+
}
1781+
17301782
return [
1731-
'node' => new Symbol($matches[1]),
1732-
'pos' => $pos + strlen($matches[0]),
1783+
'node' => $symbol,
1784+
'pos' => $endPos,
17331785
];
17341786
}
17351787

tests/TestCase/Parser/InlineParserTest.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66

77
use Djot\Node\Block\Paragraph;
88
use Djot\Node\Inline\Code;
9+
use Djot\Node\Inline\Delete;
910
use Djot\Node\Inline\Emphasis;
1011
use Djot\Node\Inline\HardBreak;
12+
use Djot\Node\Inline\Highlight;
1113
use Djot\Node\Inline\Image;
14+
use Djot\Node\Inline\Insert;
1215
use Djot\Node\Inline\Link;
1316
use Djot\Node\Inline\Math;
1417
use Djot\Node\Inline\SoftBreak;
@@ -474,4 +477,167 @@ public function testBooleanAttributeWithQuotedValueAndClass(): void
474477
$this->assertNull($link->getAttribute('Download'));
475478
$this->assertNull($link->getAttribute('file'));
476479
}
480+
481+
// ===== Trailing Attributes for Inline Elements =====
482+
483+
public function testEmphasisWithTrailingAttributes(): void
484+
{
485+
$para = $this->parseInline('_emphasized text_{.highlight}');
486+
487+
$em = $this->getFirstChild($para);
488+
$this->assertInstanceOf(Emphasis::class, $em);
489+
$this->assertSame('highlight', $em->getAttribute('class'));
490+
}
491+
492+
public function testStrongWithTrailingAttributes(): void
493+
{
494+
$para = $this->parseInline('*strong text*{.important #main}');
495+
496+
$strong = $this->getFirstChild($para);
497+
$this->assertInstanceOf(Strong::class, $strong);
498+
$this->assertSame('important', $strong->getAttribute('class'));
499+
$this->assertSame('main', $strong->getAttribute('id'));
500+
}
501+
502+
public function testCodeSpanWithTrailingAttributes(): void
503+
{
504+
$para = $this->parseInline('`code`{.lang-js}');
505+
506+
$code = $this->getFirstChild($para);
507+
$this->assertInstanceOf(Code::class, $code);
508+
$this->assertSame('lang-js', $code->getAttribute('class'));
509+
}
510+
511+
public function testCodeSpanWithMultipleAttributes(): void
512+
{
513+
$para = $this->parseInline('`const x = 1`{.javascript data-line="5"}');
514+
515+
$code = $this->getFirstChild($para);
516+
$this->assertInstanceOf(Code::class, $code);
517+
$this->assertSame('javascript', $code->getAttribute('class'));
518+
$this->assertSame('5', $code->getAttribute('data-line'));
519+
}
520+
521+
public function testSuperscriptWithTrailingAttributes(): void
522+
{
523+
$para = $this->parseInline('^2^{.exponent}');
524+
525+
$sup = $this->getFirstChild($para);
526+
$this->assertInstanceOf(Superscript::class, $sup);
527+
$this->assertSame('exponent', $sup->getAttribute('class'));
528+
}
529+
530+
public function testSubscriptWithTrailingAttributes(): void
531+
{
532+
$para = $this->parseInline('~2~{.chemical}');
533+
534+
$sub = $this->getFirstChild($para);
535+
$this->assertInstanceOf(Subscript::class, $sub);
536+
$this->assertSame('chemical', $sub->getAttribute('class'));
537+
}
538+
539+
public function testBracedSuperscriptWithTrailingAttributes(): void
540+
{
541+
$para = $this->parseInline('{^text^}{.ref}');
542+
543+
$sup = $this->getFirstChild($para);
544+
$this->assertInstanceOf(Superscript::class, $sup);
545+
$this->assertSame('ref', $sup->getAttribute('class'));
546+
}
547+
548+
public function testBracedSubscriptWithTrailingAttributes(): void
549+
{
550+
$para = $this->parseInline('{~text~}{.formula}');
551+
552+
$sub = $this->getFirstChild($para);
553+
$this->assertInstanceOf(Subscript::class, $sub);
554+
$this->assertSame('formula', $sub->getAttribute('class'));
555+
}
556+
557+
public function testHighlightWithTrailingAttributes(): void
558+
{
559+
$para = $this->parseInline('{=highlighted=}{.match}');
560+
561+
$mark = $this->getFirstChild($para);
562+
$this->assertInstanceOf(Highlight::class, $mark);
563+
$this->assertSame('match', $mark->getAttribute('class'));
564+
}
565+
566+
public function testInsertWithTrailingAttributes(): void
567+
{
568+
$para = $this->parseInline('{+inserted+}{.added}');
569+
570+
$ins = $this->getFirstChild($para);
571+
$this->assertInstanceOf(Insert::class, $ins);
572+
$this->assertSame('added', $ins->getAttribute('class'));
573+
}
574+
575+
public function testDeleteWithTrailingAttributes(): void
576+
{
577+
$para = $this->parseInline('{-deleted-}{.removed}');
578+
579+
$del = $this->getFirstChild($para);
580+
$this->assertInstanceOf(Delete::class, $del);
581+
$this->assertSame('removed', $del->getAttribute('class'));
582+
}
583+
584+
public function testSymbolWithTrailingAttributes(): void
585+
{
586+
$para = $this->parseInline(':emoji:{.large}');
587+
588+
$symbol = $this->getFirstChild($para);
589+
$this->assertInstanceOf(Symbol::class, $symbol);
590+
$this->assertSame('emoji', $symbol->getName());
591+
$this->assertSame('large', $symbol->getAttribute('class'));
592+
}
593+
594+
public function testTrailingAttributesDoNotAffectFollowingText(): void
595+
{
596+
$para = $this->parseInline('_text_{.cls} more text');
597+
598+
$children = $para->getChildren();
599+
$this->assertCount(2, $children);
600+
601+
$em = $children[0];
602+
$this->assertInstanceOf(Emphasis::class, $em);
603+
$this->assertSame('cls', $em->getAttribute('class'));
604+
605+
$text = $children[1];
606+
$this->assertInstanceOf(Text::class, $text);
607+
$this->assertSame(' more text', $text->getContent());
608+
}
609+
610+
public function testMultipleInlineElementsWithTrailingAttributes(): void
611+
{
612+
$para = $this->parseInline('_em_{.a} and *strong*{.b}');
613+
614+
$children = $para->getChildren();
615+
$this->assertCount(3, $children);
616+
617+
$this->assertInstanceOf(Emphasis::class, $children[0]);
618+
$this->assertSame('a', $children[0]->getAttribute('class'));
619+
620+
$this->assertInstanceOf(Text::class, $children[1]);
621+
622+
$this->assertInstanceOf(Strong::class, $children[2]);
623+
$this->assertSame('b', $children[2]->getAttribute('class'));
624+
}
625+
626+
public function testNestedEmphasisWithTrailingAttributes(): void
627+
{
628+
$para = $this->parseInline('_outer *inner*_{.outer-class}');
629+
630+
$em = $this->getFirstChild($para);
631+
$this->assertInstanceOf(Emphasis::class, $em);
632+
$this->assertSame('outer-class', $em->getAttribute('class'));
633+
}
634+
635+
public function testInlineElementWithoutTrailingAttributesStillWorks(): void
636+
{
637+
$para = $this->parseInline('_plain emphasis_ text');
638+
639+
$em = $this->getFirstChild($para);
640+
$this->assertInstanceOf(Emphasis::class, $em);
641+
$this->assertEmpty($em->getAttributes());
642+
}
477643
}

0 commit comments

Comments
 (0)