Skip to content
Merged
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
5 changes: 4 additions & 1 deletion src/Converter/HtmlToDjot.php
Original file line number Diff line number Diff line change
Expand Up @@ -488,8 +488,11 @@ protected function processDefinitionList(DOMElement $node): string
$ddCount = 0;
} elseif ($tag === 'dd') {
// Definition: indented content after blank line
if ($lastWasTerm || $ddCount > 0) {
if ($lastWasTerm) {
$output .= "\n";
} elseif ($ddCount > 0) {
// Multiple dd elements for same term - use continuation marker
$output .= ": +\n\n";
}
$content = trim($this->processChildren($child));
// Indent definition content
Expand Down
82 changes: 38 additions & 44 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,11 @@ protected function tryParseDjotDefinitionList(Node $parent, array $lines, int $s

$termContent = $termMatch[1];

// Check for continuation marker `: +` - not a new term, breaks term collection
if ($termContent === '+') {
break;
}

// Special case: if term starts with code fence, term is empty and fence is part of definition
$termStartsWithCodeFence = preg_match('/^(`{3,}|~{3,})/', $termContent, $fenceMatch);

Expand Down Expand Up @@ -1880,9 +1885,9 @@ protected function tryParseDjotDefinitionList(Node $parent, array $lines, int $s
}

// Now collect definition content (after blank line, 2-space indent)
// When multiple terms share definitions, each paragraph block becomes a separate dd
// Use `: +` marker to create additional dd elements for the same term
$defLines = [];
$multipleTerms = count($terms) > 1;
$allDefBlocks = [];

// If term started with code fence, add it to definition content
if ($codeFenceInfo !== null) {
Expand All @@ -1899,6 +1904,17 @@ protected function tryParseDjotDefinitionList(Node $parent, array $lines, int $s
continue;
}

// Check for continuation marker `: +` - creates new dd for same term
if ($defLine === ': +') {
if ($defLines !== []) {
$allDefBlocks[] = $defLines;
$defLines = [];
}
$i++;

continue;
}

// Check for next term (space is syntax delimiter, not tab)
if (preg_match('/^: +/', $defLine)) {
break;
Expand All @@ -1913,22 +1929,35 @@ protected function tryParseDjotDefinitionList(Node $parent, array $lines, int $s
}
}

// Add final block
if ($defLines !== []) {
$allDefBlocks[] = $defLines;
}

// Create definition node(s)
if ($multipleTerms && $defLines !== []) {
// Split by blank lines - each block becomes a separate dd
$blocks = $this->splitByBlankLines($defLines);
foreach ($blocks as $block) {
if ($allDefBlocks !== []) {
foreach ($allDefBlocks as $block) {
$def = new DefinitionDescription();
$defAttributes = [];

// Skip leading/trailing blank lines
while ($block !== [] && $block[0] === '') {
array_shift($block);
}
while ($block !== [] && end($block) === '') {
array_pop($block);
}

// Check if last line is a standalone attribute block for the dd
$blockCount = count($block);
if ($blockCount > 0 && preg_match('/^\{([^{}]+)\}\s*$/', $block[$blockCount - 1], $attrMatch)) {
$defAttributes = AttributeParser::parse($attrMatch[1]);
array_pop($block);
}

$this->parseBlocks($def, $block, 0);
if ($block !== []) {
$this->parseBlocks($def, $block, 0);
}

// Apply definition attributes
if ($defAttributes !== []) {
Expand All @@ -1939,43 +1968,8 @@ protected function tryParseDjotDefinitionList(Node $parent, array $lines, int $s
$defList->appendChild($def);
}
} else {
// Single term: all content goes in one dd
$def = new DefinitionDescription();
$defAttributes = [];
if ($defLines !== []) {
// Skip leading blank lines using index (avoid O(n) array_shift)
$defStart = 0;
$defLineCount = count($defLines);
while ($defStart < $defLineCount && $defLines[$defStart] === '') {
$defStart++;
}
// Remove trailing blank lines (but preserve potential attribute line)
$defEnd = $defLineCount;
while ($defEnd > $defStart + 1 && $defLines[$defEnd - 1] === '') {
$defEnd--;
}

// Check if last line is a standalone attribute block for the dd
if ($defEnd > $defStart && preg_match('/^\{([^{}]+)\}\s*$/', $defLines[$defEnd - 1], $attrMatch)) {
$defAttributes = AttributeParser::parse($attrMatch[1]);
$defEnd--;
// Remove any trailing blank lines before the attribute
while ($defEnd > $defStart && $defLines[$defEnd - 1] === '') {
$defEnd--;
}
}

if ($defEnd > $defStart) {
$this->parseBlocks($def, array_slice($defLines, $defStart, $defEnd - $defStart), 0);
}
}
// Apply definition attributes
if ($defAttributes !== []) {
foreach ($defAttributes as $key => $value) {
$def->setAttribute($key, $value);
}
}
$defList->appendChild($def);
// Term with no definition content - create empty dd
$defList->appendChild(new DefinitionDescription());
}
}

Expand Down
17 changes: 17 additions & 0 deletions tests/OfficialTestSuiteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@
#[Group('official')]
class OfficialTestSuiteTest extends TestCase
{
/**
* Tests to skip due to intentional spec deviations
*
* Format: 'filename_index' => 'reason for deviation'
*
* @var array<string, string>
*/
protected const INTENTIONAL_DEVIATIONS = [
// Currently no intentional deviations
];

protected DjotConverter $converter;

protected function setUp(): void
Expand Down Expand Up @@ -135,6 +146,12 @@ public static function officialTestProvider(): array
#[DataProvider('officialTestProvider')]
public function testOfficialSuite(string $input, string $expected, string $file, int $index): void
{
$testName = basename($file, '.test') . '_' . $index;

if (isset(self::INTENTIONAL_DEVIATIONS[$testName])) {
$this->markTestSkipped('Intentional deviation: ' . self::INTENTIONAL_DEVIATIONS[$testName]);
}

$result = $this->converter->convert($input);

// Normalize whitespace for comparison
Expand Down
8 changes: 5 additions & 3 deletions tests/TestCase/Converter/HtmlToDjotTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,16 @@ public function testDefinitionListMultipleTerms(): void

public function testDefinitionListMultipleDefinitions(): void
{
// Multiple dd elements under multiple terms become separate indented paragraphs
// Multiple dd elements use `: +` continuation marker
$html = '<dl><dt>color</dt><dt>colour</dt><dd>The visual property.</dd><dd>Used in design.</dd></dl>';
$result = $this->converter->convert($html);

$this->assertStringContainsString(': color', $result);
$this->assertStringContainsString(': colour', $result);
// Each dd becomes a separate indented block separated by blank line
$this->assertStringContainsString(" The visual property.\n\n Used in design.", $result);
// First dd is indented content
$this->assertStringContainsString(' The visual property.', $result);
// Second dd uses continuation marker
$this->assertStringContainsString(": +\n\n Used in design.", $result);
}

// ==================== Spans with Attributes ====================
Expand Down
9 changes: 5 additions & 4 deletions tests/TestCase/DjotConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -555,16 +555,16 @@ public function testDefinitionListMultipleTermsWithBlankLines(): void

public function testDefinitionListMultipleTermsMultipleDefinitions(): void
{
// When multiple terms share definitions, each paragraph block becomes a separate dd
$djot = ": color\n: colour\n\n The visual property of objects.\n\n Used in art and design.";
// Use `: +` continuation marker to create multiple dd elements
$djot = ": color\n: colour\n\n The visual property of objects.\n\n: +\n\n Used in art and design.";

$result = $this->converter->convert($djot);

$this->assertStringContainsString('<dt>color</dt>', $result);
$this->assertStringContainsString('<dt>colour</dt>', $result);
$this->assertStringContainsString('The visual property', $result);
$this->assertStringContainsString('Used in art and design', $result);
// Multiple terms with blank-line-separated paragraphs = multiple dd elements
// `: +` marker creates second dd element
$this->assertSame(2, substr_count($result, '<dd>'));
}

Expand Down Expand Up @@ -601,7 +601,8 @@ public function testDefinitionListDdAttribute(): void
public function testDefinitionListAllAttributes(): void
{
// DD attributes come AFTER content (consistent with list items)
$djot = "{.vocabulary}\n: color\n{.american}\n: colour\n{.british}\n\n The visual property.\n {.primary}\n\n Used in design.\n {.secondary}";
// Use `: +` continuation marker to create multiple dd elements with separate attributes
$djot = "{.vocabulary}\n: color\n{.american}\n: colour\n{.british}\n\n The visual property.\n {.primary}\n\n: +\n\n Used in design.\n {.secondary}";

$result = $this->converter->convert($djot);

Expand Down
44 changes: 44 additions & 0 deletions tests/TestCase/Parser/BlockParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,50 @@ public function testParseDefinitionListDdAttributeBeforeContentNotParsed(): void
$this->assertNull($dd->getAttribute('class'));
}

public function testParseDefinitionListBlankLineKeepsSameDd(): void
{
// Blank lines within definition content create paragraphs in same dd (spec behavior)
$djot = ": Term\n\n First paragraph\n\n Second paragraph";
$doc = $this->parser->parse($djot);

$dl = $doc->getChildren()[0];
$this->assertInstanceOf(DefinitionList::class, $dl);

// Should have: dt + dd = 2 children (one dd with multiple paragraphs)
$children = $dl->getChildren();
$dds = array_filter($children, fn ($c) => $c->getType() === 'definition_description');
$this->assertCount(1, $dds);
}

public function testParseDefinitionListContinuationMarker(): void
{
// `: +` marker creates additional dd for same term
$djot = ": Term\n\n First definition\n\n: +\n\n Second definition";
$doc = $this->parser->parse($djot);

$dl = $doc->getChildren()[0];
$this->assertInstanceOf(DefinitionList::class, $dl);

// Should have: dt + dd + dd = 3 children
$children = $dl->getChildren();
$dds = array_filter($children, fn ($c) => $c->getType() === 'definition_description');
$this->assertCount(2, $dds);
}

public function testParseDefinitionListEmptyDd(): void
{
// Term with no definition content should still create empty dd
$djot = ': Term';
$doc = $this->parser->parse($djot);

$dl = $doc->getChildren()[0];
$this->assertInstanceOf(DefinitionList::class, $dl);

$children = $dl->getChildren();
$dds = array_filter($children, fn ($c) => $c->getType() === 'definition_description');
$this->assertCount(1, $dds);
}

public function testParseThematicBreak(): void
{
$doc = $this->parser->parse('---');
Expand Down
5 changes: 3 additions & 2 deletions tests/TestCase/Renderer/MarkdownRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,15 @@ public function testDefinitionList(): void

public function testDefinitionListMultipleTermsMultipleDefinitions(): void
{
$djot = ": color\n: colour\n\n The visual property.\n\n Used in design.";
// Use `: +` continuation marker to create multiple dd elements
$djot = ": color\n: colour\n\n The visual property.\n\n: +\n\n Used in design.";
$document = $this->converter->parse($djot);
$result = $this->renderer->render($document);

// Multiple terms
$this->assertStringContainsString('**color**', $result);
$this->assertStringContainsString('**colour**', $result);
// Multiple definitions
// Multiple definitions (created via `: +` marker)
$this->assertSame(2, substr_count($result, ': '));
$this->assertStringContainsString('The visual property.', $result);
$this->assertStringContainsString('Used in design.', $result);
Expand Down