From 71e2c9789b1884907b0248160b14f57fde20b679 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 18 Jul 2026 12:45:51 +0200 Subject: [PATCH] Add opt-in source-line tracking for editor scroll-sync Add a `sourceLines` option (default off) that stamps each top-level block element with a `data-source-line` attribute holding the 1-based source line where the block started. This lets an editor live-preview map rendered blocks back to the source textarea for accurate scroll synchronization. - BlockParser gains a `trackSourceLines` constructor flag; when set, the main block loop stamps newly appended top-level children (the document's direct children) via a small helper. The attribute is added to the node, so it renders through the normal attribute path, after any author attributes. - DjotConverter exposes the flag as a `sourceLines` constructor option, threaded to the default parser. - Off by default, so normal rendering output is unchanged. Raw HTML blocks and comments are not stamped. Documented in the API reference (new "Source-line tracking" section) and the README feature list. --- README.md | 1 + docs/reference/api.md | 41 ++++++++++++++++++ src/DjotConverter.php | 4 +- src/Parser/BlockParser.php | 52 ++++++++++++++++++++++- tests/TestCase/SourceLineTrackingTest.php | 50 ++++++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/TestCase/SourceLineTrackingTest.php diff --git a/README.md b/README.md index 80a2d758..42cec6a9 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/reference/api.md b/docs/reference/api.md index ac04c7ad..e087aa74 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -28,6 +28,7 @@ public function __construct( bool $nestedBlocksInLists = false, bool $blocksInterruptParagraphs = false, bool $nestedListsWithoutBlankLine = false, + bool $sourceLines = false, ) ``` @@ -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 @@ -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 +
+

Heading

+

A paragraph.

+
+``` + +- 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. + `

`. +- 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. diff --git a/src/DjotConverter.php b/src/DjotConverter.php index e9121f8e..18dc41bc 100644 --- a/src/DjotConverter.php +++ b/src/DjotConverter.php @@ -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, @@ -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; @@ -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 diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index 758f2ff5..110958dd 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -220,6 +220,16 @@ 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, @@ -227,6 +237,7 @@ public function __construct( bool $nestedBlocksInLists = false, bool $blocksInterruptParagraphs = false, bool $nestedListsWithoutBlankLine = false, + bool $trackSourceLines = false, ) { $this->collectWarnings = $collectWarnings; $this->strictMode = $strictMode; @@ -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(); @@ -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; @@ -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; } @@ -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 * diff --git a/tests/TestCase/SourceLineTrackingTest.php b/tests/TestCase/SourceLineTrackingTest.php new file mode 100644 index 00000000..929c6837 --- /dev/null +++ b/tests/TestCase/SourceLineTrackingTest.php @@ -0,0 +1,50 @@ +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('/]*data-source-line="3"/', $html); + $this->assertMatchesRegularExpression('/]*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('

', $html); + } +}