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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ $html = $converter->convert('Hello *world*!');
- **Multiple renderers**: HTML, plain text, Markdown, ANSI terminal output
- **Extensions**: Built-in extensions for external links, TOC, heading permalinks, @mentions, autolinks, default attributes, and citations
- **Extensible**: Custom inline/block patterns, render events
- **Editor integration**: Opt-in `data-source-line` stamping on top-level blocks for live-preview scroll-sync (`new DjotConverter(sourceLines: true)`) — see [Source-line tracking](https://php-collective.github.io/djot-php/reference/api#source-line-tracking)
- **File support**: Parse and convert files directly
- **CLI tools**: `bin/djot` (one-shot convert) and `bin/djot-watch` (live-reload preview server) — see [CLI reference](https://php-collective.github.io/djot-php/reference/cli)

Expand Down
41 changes: 41 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function __construct(
bool $nestedBlocksInLists = false,
bool $blocksInterruptParagraphs = false,
bool $nestedListsWithoutBlankLine = false,
bool $sourceLines = false,
)
```

Expand All @@ -44,6 +45,7 @@ public function __construct(
- `$nestedBlocksInLists`: **Deprecated.** When `true`, indentation alone introduces nested blocks of **any** type inside list items without a blank line (broad, eager - no lone-marker lookahead), while top-level paragraph interruption stays spec-compliant (see [Nested Blocks in Lists Mode](/guide/parser-options#nested-blocks-in-lists-mode)). Prefer `$blocksInterruptParagraphs` + `$nestedListsWithoutBlankLine`. No longer implied by `$significantNewlines`.
- `$blocksInterruptParagraphs`: When `true`, top-level block elements (lists, blockquotes, headings, tables, thematic breaks, and code/div/comment fences) can interrupt a paragraph without a preceding blank line. It **also** interrupts a list item's lead paragraph, so an indented non-list block nests inside the item without a blank line, using the **same** lone-marker lookahead as the top level: unambiguous openers (`#`, fenced code, `:::`, `---`) and real multi-line blocks nest, while a single ambiguous marker line (`>`, `|`) stays literal. It does not nest sublists (see [Block Interrupts Paragraphs Mode](/guide/parser-options#block-interrupts-paragraphs-mode)). Implied by `$significantNewlines`.
- `$nestedListsWithoutBlankLine`: When `true`, a sublist nests inside a list item without a blank line. Only sublists nest; non-list blocks under the item stay literal and top-level paragraph interruption is unaffected (see [Nested Lists Without Blank Line Mode](/guide/parser-options#nested-lists-without-blank-line-mode)). Implied by `$significantNewlines`.
- `$sourceLines`: When `true`, each top-level block element is stamped with a `data-source-line` attribute holding the **1-based** source line where the block started. Off by default, so normal output is unchanged. Intended for editor live-preview scroll-sync (map a rendered block back to the source textarea). Ignored when a pre-configured `$parser` is supplied (pass `new BlockParser(trackSourceLines: true)` instead). See [Source-line tracking](#source-line-tracking).

### Factory Methods

Expand Down Expand Up @@ -391,6 +393,45 @@ $converter->setSafeMode(SafeMode::strict());
$converter->setSafeMode(false);
```

## Source-line tracking

Opt-in `data-source-line` stamping links each rendered block back to the source
line it came from. It is off by default, so normal output is unchanged.

```php
$converter = new DjotConverter(sourceLines: true);

echo $converter->convert("# Heading\n\nA paragraph.\n");
```

```html
<section id="Heading">
<h1 data-source-line="1">Heading</h1>
<p data-source-line="3">A paragraph.</p>
</section>
```

- Only **top-level** blocks are stamped (a paragraph nested inside a blockquote
is not). The value is the **1-based** source line where the block starts.
- The attribute renders **after** any author attributes, e.g.
`<p class="note" data-source-line="2">`.
- Raw HTML blocks and comments are not stamped (no reliable element tag).

The typical use is editor live-preview scroll synchronization: read the line of
the block at the top of the source pane, find the element whose
`data-source-line` matches in the rendered pane, and scroll it into view. The
1-based value matches editor gutters (Monaco / CodeMirror).

When you construct the parser yourself, enable it there instead — the converter
`sourceLines` flag is ignored once a pre-configured `$parser` is passed:

```php
use Djot\Parser\BlockParser;

$parser = new BlockParser(trackSourceLines: true);
$converter = new DjotConverter(parser: $parser);
```

## Error Handling

The parser can optionally report warnings and errors with line/column information.
Expand Down
4 changes: 3 additions & 1 deletion src/DjotConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ public static function ansi(?BlockParser $parser = null): self
* @param bool $nestedBlocksInLists Allow nested blocks in list items without blank lines (deprecated; prefer blocksInterruptParagraphs + nestedListsWithoutBlankLine)
* @param bool $blocksInterruptParagraphs Allow top-level block elements to interrupt paragraphs without a blank line
* @param bool $nestedListsWithoutBlankLine Allow sublists to nest in list items without a blank line
* @param bool $sourceLines Stamp top-level block elements with a `data-source-line` attribute (1-based source line). Opt-in, for editor scroll-sync; ignored when a pre-configured $parser is supplied.
*/
public function __construct(
bool $xhtml = false,
Expand All @@ -146,6 +147,7 @@ public function __construct(
bool $nestedBlocksInLists = false,
bool $blocksInterruptParagraphs = false,
bool $nestedListsWithoutBlankLine = false,
bool $sourceLines = false,
) {
$this->collectWarnings = $warnings;
$this->strictMode = $strict;
Expand All @@ -154,7 +156,7 @@ public function __construct(
if ($parser !== null) {
$this->parser = $parser;
} else {
$this->parser = new BlockParser($warnings, $strict, $significantNewlines, $nestedBlocksInLists, $blocksInterruptParagraphs, $nestedListsWithoutBlankLine);
$this->parser = new BlockParser($warnings, $strict, $significantNewlines, $nestedBlocksInLists, $blocksInterruptParagraphs, $nestedListsWithoutBlankLine, $sourceLines);
}

// Use provided renderer or create one from parameters
Expand Down
52 changes: 51 additions & 1 deletion src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,24 @@ class BlockParser
*/
protected ?Closure $headingIdTransformer = null;

/**
* When true, top-level block nodes are stamped with a `data-source-line`
* attribute holding the 1-based source line where the block started.
* Opt-in (default off): used by editor live-preview to sync scroll to the
* source textarea. Off by default so normal rendering output is unchanged.
*
* @var bool
*/
protected bool $trackSourceLines = false;

public function __construct(
bool $collectWarnings = false,
bool $strictMode = false,
bool $significantNewlines = false,
bool $nestedBlocksInLists = false,
bool $blocksInterruptParagraphs = false,
bool $nestedListsWithoutBlankLine = false,
bool $trackSourceLines = false,
) {
$this->collectWarnings = $collectWarnings;
$this->strictMode = $strictMode;
Expand All @@ -235,6 +246,7 @@ public function __construct(
$this->blocksInterruptParagraphs = $blocksInterruptParagraphs || $significantNewlines;
$this->nestedBlocksInLists = $nestedBlocksInLists;
$this->nestedListsWithoutBlankLine = $nestedListsWithoutBlankLine || $significantNewlines;
$this->trackSourceLines = $trackSourceLines;
$this->inlineParser = new InlineParser($this);
$this->listParser = new ListParser();
$this->tableParser = new TableParser();
Expand Down Expand Up @@ -973,9 +985,18 @@ private function parseBlocksImpl(Node $parent, array $lines, int $indent): void
continue;
}

// Source-line tracking (opt-in): remember where this block starts and
// how many children the parent had, so newly appended top-level blocks
// can be stamped with `data-source-line` after the dispatch below.
// Only the document's direct children are stamped (nested blocks are
// skipped via the -1 sentinel).
$blockStart = $i;
$childrenBefore = ($this->trackSourceLines && $parent instanceof Document) ? count($parent->getChildren()) : -1;

// Try custom block patterns first (before built-in syntax)
$customConsumed = $this->tryCustomBlockPatterns($parent, $lines, $i);
if ($customConsumed !== null) {
$this->stampSourceLine($parent, $childrenBefore, $blockStart);
$i += $customConsumed;

continue;
Expand All @@ -993,7 +1014,9 @@ private function parseBlocksImpl(Node $parent, array $lines, int $indent): void
&& !str_contains(self::BLOCK_MARKER_CHARS, $marker)
&& !($marker >= 'A' && preg_match('/^[ \t]*[A-Za-z]+[.)](?:\{[^{}]+\})?([ \t]|$)/', $line) === 1)
) {
$i += $this->tryParseParagraph($parent, $lines, $i);
$consumedFast = $this->tryParseParagraph($parent, $lines, $i);
$this->stampSourceLine($parent, $childrenBefore, $blockStart);
$i += $consumedFast;

continue;
}
Expand All @@ -1019,10 +1042,37 @@ private function parseBlocksImpl(Node $parent, array $lines, int $indent): void
?? $this->tryParseCaption($parent, $lines, $i)
?? $this->tryParseParagraph($parent, $lines, $i);

$this->stampSourceLine($parent, $childrenBefore, $blockStart);
$i += $consumed;
}
}

/**
* Stamp `data-source-line` on any children appended to $parent since
* $childrenBefore, using the 1-based source line the block started on.
* No-op unless source-line tracking is enabled (childrenBefore === -1).
*
* @param \Djot\Node\Node $parent
* @param int $childrenBefore Child count before the block was parsed, or -1 when disabled.
* @param int $start 0-indexed source line index; emitted as 1-based (+1).
*
* @return void
*/
private function stampSourceLine(Node $parent, int $childrenBefore, int $start): void
{
if ($childrenBefore < 0) {
return;
}

$children = $parent->getChildren();
$total = count($children);
for ($k = $childrenBefore; $k < $total; $k++) {
if ($children[$k]->getAttribute('data-source-line') === null) {
$children[$k]->setAttribute('data-source-line', (string)($start + 1));
}
}
}

/**
* Try to match custom block patterns at the current position
*
Expand Down
50 changes: 50 additions & 0 deletions tests/TestCase/SourceLineTrackingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Djot\Test\TestCase;

use Djot\DjotConverter;
use PHPUnit\Framework\TestCase;

class SourceLineTrackingTest extends TestCase
{
public function testDisabledByDefaultEmitsNoSourceLineAttribute(): void
{
$converter = new DjotConverter();
$html = $converter->convert("# Heading\n\nParagraph one.\n");

$this->assertStringNotContainsString('data-source-line', $html);
}

public function testEnabledStampsTopLevelBlocksWithOneBasedSourceLine(): void
{
$converter = new DjotConverter(sourceLines: true);
// 1-based lines: 1 "# Heading", 3 "Paragraph one.", 5 "Paragraph two."
$html = $converter->convert("# Heading\n\nParagraph one.\n\nParagraph two.\n");

$this->assertStringContainsString('data-source-line="1"', $html);
$this->assertStringContainsString('data-source-line="3"', $html);
$this->assertStringContainsString('data-source-line="5"', $html);
}

public function testEnabledStampsListAndBlockquote(): void
{
$converter = new DjotConverter(sourceLines: true);
// 1-based: 1 "Intro", 3 "- item a", 4 "- item b", 6 "> quote"
$html = $converter->convert("Intro\n\n- item a\n- item b\n\n> quote\n");

// The list block and the blockquote each carry a source line.
$this->assertMatchesRegularExpression('/<ul[^>]*data-source-line="3"/', $html);
$this->assertMatchesRegularExpression('/<blockquote[^>]*data-source-line="6"/', $html);
}

public function testSourceLineRendersAfterAuthorAttributes(): void
{
$converter = new DjotConverter(sourceLines: true);
$html = $converter->convert("{.note}\nParagraph with class.\n");

// The attribute is appended after author attributes (attribute order).
$this->assertStringContainsString('<p class="note" data-source-line="2">', $html);
}
}
Loading