From 52c52d0f5b2ff6bfdbee7d3f160c7d3ddac0735d Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Mon, 22 Jul 2024 13:05:18 +0400 Subject: [PATCH 01/20] New helper `MarkdownDocument`. --- .../src/Utils/MarkdownDocument.php | 131 +++++++++++++++ .../src/Utils/MarkdownDocumentTest.php | 153 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 packages/documentator/src/Utils/MarkdownDocument.php create mode 100644 packages/documentator/src/Utils/MarkdownDocumentTest.php diff --git a/packages/documentator/src/Utils/MarkdownDocument.php b/packages/documentator/src/Utils/MarkdownDocument.php new file mode 100644 index 00000000..aea0479f --- /dev/null +++ b/packages/documentator/src/Utils/MarkdownDocument.php @@ -0,0 +1,131 @@ + + */ + private array $lines; + private Document $node; + + private ?string $title = null; + private ?string $summary = null; + + public function __construct(string $string) { + $this->node = $this->parse($string); + $this->lines = preg_split('/\R/u', $string) ?: []; + } + + /** + * Returns the first `# Header` if present. + */ + public function getTitle(): ?string { + if ($this->title === null) { + $title = $this->getFirstNode($this->node, Heading::class, static fn ($n) => $n->getLevel() === 1); + $title = $this->getText($title); + $title = trim(ltrim("{$title}", '#')) ?: null; + $this->title = $title; + } + + return $this->title; + } + + /** + * Returns the first paragraph right after `# Header` if present. + */ + public function getSummary(): ?string { + if ($this->summary === null) { + $title = $this->getFirstNode($this->node, Heading::class, static fn ($n) => $n->getLevel() === 1); + $this->summary = $this->getText($this->getFirstNode($title?->next(), Paragraph::class)); + } + + return $this->summary; + } + + protected function parse(string $string): Document { + $converter = new GithubFlavoredMarkdownConverter(); + $environment = $converter->getEnvironment(); + $parser = new MarkdownParser($environment); + + return $parser->parse($string); + } + + protected function getText(?AbstractBlock $node): ?string { + if ($node?->getStartLine() === null || $node->getEndLine() === null) { + return null; + } + + $start = $node->getStartLine() - 1; + $end = $node->getEndLine() - 1; + $lines = array_slice($this->lines, $start, $end - $start + 1); + $text = implode("\n", $lines); + + return $text; + } + + /** + * @template T of Node + * + * @param class-string $class + * @param Closure(T): bool $filter + * + * @return ?T + */ + protected function getFirstNode(?Node $node, string $class, ?Closure $filter = null): ?Node { + // Null? + if ($node === null) { + return null; + } + + // Wanted? + if ($node instanceof $class && ($filter === null || $filter($node))) { + return $node; + } + + // Comment? + if ( + $node instanceof HtmlBlock + && str_starts_with($node->getLiteral(), '') + ) { + return $this->getFirstNode($node->next(), $class, $filter); + } + + // Document? + if ($node instanceof Document) { + return $this->getFirstNode($node->firstChild(), $class, $filter); + } + + // Not found + return null; + } + + #[Override] + public function __toString(): string { + return implode("\n", $this->lines); + } +} diff --git a/packages/documentator/src/Utils/MarkdownDocumentTest.php b/packages/documentator/src/Utils/MarkdownDocumentTest.php new file mode 100644 index 00000000..5539791f --- /dev/null +++ b/packages/documentator/src/Utils/MarkdownDocumentTest.php @@ -0,0 +1,153 @@ +getTitle(), + ); + self::assertNull( + (new MarkdownDocument( + <<<'MARKDOWN' + fsdfsdfsdf + + # Header + MARKDOWN, + )) + ->getTitle(), + ); + self::assertNull( + (new MarkdownDocument( + <<<'MARKDOWN' + # + + fsdfsdfsdf + MARKDOWN, + )) + ->getTitle(), + ); + self::assertEquals( + 'Header', + (new MarkdownDocument( + <<<'MARKDOWN' + + # Header + + fsdfsdfsdf + MARKDOWN, + )) + ->getTitle(), + ); + self::assertEquals( + 'Header', + (new MarkdownDocument( + <<<'MARKDOWN' + + + # Header + + fsdfsdfsdf + MARKDOWN, + )) + ->getTitle(), + ); + } + + public function testGetSummary(): void { + self::assertNull( + (new MarkdownDocument( + <<<'MARKDOWN' + ## Header A + # Header B + + sdfsdfsdf + MARKDOWN, + )) + ->getSummary(), + ); + self::assertNull( + (new MarkdownDocument( + <<<'MARKDOWN' + fsdfsdfsdf + + # Header + + sdfsdfsdf + MARKDOWN, + )) + ->getSummary(), + ); + self::assertNull( + (new MarkdownDocument( + <<<'MARKDOWN' + # Header + + > Not a paragraph + + fsdfsdfsdf + MARKDOWN, + )) + ->getSummary(), + ); + self::assertEquals( + 'fsdfsdfsdf', + (new MarkdownDocument( + <<<'MARKDOWN' + # + + fsdfsdfsdf + MARKDOWN, + )) + ->getSummary(), + ); + self::assertEquals( + <<<'TEXT' + fsdfsdfsdf + fsdfsdfsdf + TEXT, + (new MarkdownDocument( + <<<'MARKDOWN' + + # Header + + fsdfsdfsdf + fsdfsdfsdf + MARKDOWN, + )) + ->getSummary(), + ); + self::assertEquals( + <<<'TEXT' + fsdfsdfsdf + fsdfsdfsdf + TEXT, + (new MarkdownDocument( + <<<'MARKDOWN' + + + # Header + + + + fsdfsdfsdf + fsdfsdfsdf + MARKDOWN, + )) + ->getSummary(), + ); + } +} From c59529dd772f66ee00bc47cd6a16aeff564052c3 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:32:45 +0400 Subject: [PATCH 02/20] `MarkdownDocument`. --- .../src/Utils/MarkdownDocument.php | 9 ++++- .../src/Utils/MarkdownDocumentTest.php | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/documentator/src/Utils/MarkdownDocument.php b/packages/documentator/src/Utils/MarkdownDocument.php index aea0479f..2f4f7dd8 100644 --- a/packages/documentator/src/Utils/MarkdownDocument.php +++ b/packages/documentator/src/Utils/MarkdownDocument.php @@ -15,6 +15,7 @@ use Stringable; use function array_slice; +use function count; use function implode; use function ltrim; use function preg_split; @@ -40,6 +41,10 @@ public function __construct(string $string) { $this->lines = preg_split('/\R/u', $string) ?: []; } + public function isEmpty(): bool { + return !$this->node->hasChildren() && count($this->node->getReferenceMap()) === 0; + } + /** * Returns the first `# Header` if present. */ @@ -60,7 +65,9 @@ public function getTitle(): ?string { public function getSummary(): ?string { if ($this->summary === null) { $title = $this->getFirstNode($this->node, Heading::class, static fn ($n) => $n->getLevel() === 1); - $this->summary = $this->getText($this->getFirstNode($title?->next(), Paragraph::class)); + $summary = $this->getText($this->getFirstNode($title?->next(), Paragraph::class)); + $summary = trim("{$summary}") ?: null; + $this->summary = $summary; } return $this->summary; diff --git a/packages/documentator/src/Utils/MarkdownDocumentTest.php b/packages/documentator/src/Utils/MarkdownDocumentTest.php index 5539791f..bb3feb97 100644 --- a/packages/documentator/src/Utils/MarkdownDocumentTest.php +++ b/packages/documentator/src/Utils/MarkdownDocumentTest.php @@ -150,4 +150,42 @@ public function testGetSummary(): void { ->getSummary(), ); } + + public function testIsEmpty(): void { + self::assertFalse( + (new MarkdownDocument( + <<<'MARKDOWN' + fsdfsdfsdf + fsdfsdfsdf + MARKDOWN, + )) + ->isEmpty(), + ); + self::assertFalse( + (new Document( + <<<'MARKDOWN' + [unused]: ../path/to/file + MARKDOWN, + )) + ->isEmpty(), + ); + self::assertFalse( + (new Document( + <<<'MARKDOWN' + + MARKDOWN, + )) + ->isEmpty(), + ); + self::assertTrue( + (new MarkdownDocument( + <<<'MARKDOWN' + + + + MARKDOWN, + )) + ->isEmpty(), + ); + } } From caeff9e404d5323e6083f1ef59c165ffefd05422 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:13:07 +0400 Subject: [PATCH 03/20] deprecate(documentator): `Markdown` helper. Please use `MarkdownDocument`/`Text` helpers instead. --- packages/documentator/src/Utils/Markdown.php | 9 +++++++++ packages/documentator/src/Utils/MarkdownTest.php | 1 + 2 files changed, 10 insertions(+) diff --git a/packages/documentator/src/Utils/Markdown.php b/packages/documentator/src/Utils/Markdown.php index e6860f86..a4ce0aa5 100644 --- a/packages/documentator/src/Utils/Markdown.php +++ b/packages/documentator/src/Utils/Markdown.php @@ -2,6 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Utils; +use LastDragon_ru\LaraASP\Documentator\Package; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock; use League\CommonMark\GithubFlavoredMarkdownConverter; @@ -21,10 +22,18 @@ use function str_ends_with; use function str_repeat; use function str_starts_with; +use function trigger_deprecation; use function trim; use const PHP_INT_MAX; +// phpcs:disable PSR1.Files.SideEffects + +trigger_deprecation(Package::Name, '%{VERSION}', 'Please use %s/%s instead.', MarkdownDocument::class, Text::class); + +/** + * @deprecated %{VERSION} Please use {@see MarkdownDocument}/{@see Text} instead. + */ class Markdown { /** * Returns the first `# Header` if present. diff --git a/packages/documentator/src/Utils/MarkdownTest.php b/packages/documentator/src/Utils/MarkdownTest.php index 8a2d3b9b..1ae789c7 100644 --- a/packages/documentator/src/Utils/MarkdownTest.php +++ b/packages/documentator/src/Utils/MarkdownTest.php @@ -7,6 +7,7 @@ /** * @internal + * @deprecated %{VERSION} */ #[CoversClass(Markdown::class)] final class MarkdownTest extends TestCase { From 4fb076361769433d13d7cabedc47dda0bb143904 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:09:32 +0400 Subject: [PATCH 04/20] `MarkdownDocument` renamed to `Document` and moved into own namespace. --- .../Document.php} | 14 ++++---- .../DocumentTest.php} | 32 +++++++++---------- packages/documentator/src/Utils/Markdown.php | 1 + 3 files changed, 24 insertions(+), 23 deletions(-) rename packages/documentator/src/{Utils/MarkdownDocument.php => Markdown/Document.php} (92%) rename packages/documentator/src/{Utils/MarkdownDocumentTest.php => Markdown/DocumentTest.php} (85%) diff --git a/packages/documentator/src/Utils/MarkdownDocument.php b/packages/documentator/src/Markdown/Document.php similarity index 92% rename from packages/documentator/src/Utils/MarkdownDocument.php rename to packages/documentator/src/Markdown/Document.php index 2f4f7dd8..dfd89a2d 100644 --- a/packages/documentator/src/Utils/MarkdownDocument.php +++ b/packages/documentator/src/Markdown/Document.php @@ -1,13 +1,13 @@ */ - private array $lines; - private Document $node; + private array $lines; + private DocumentNode $node; private ?string $title = null; private ?string $summary = null; @@ -73,7 +73,7 @@ public function getSummary(): ?string { return $this->summary; } - protected function parse(string $string): Document { + protected function parse(string $string): DocumentNode { $converter = new GithubFlavoredMarkdownConverter(); $environment = $converter->getEnvironment(); $parser = new MarkdownParser($environment); @@ -123,7 +123,7 @@ protected function getFirstNode(?Node $node, string $class, ?Closure $filter = n } // Document? - if ($node instanceof Document) { + if ($node instanceof DocumentNode) { return $this->getFirstNode($node->firstChild(), $class, $filter); } diff --git a/packages/documentator/src/Utils/MarkdownDocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php similarity index 85% rename from packages/documentator/src/Utils/MarkdownDocumentTest.php rename to packages/documentator/src/Markdown/DocumentTest.php index bb3feb97..e9b76e04 100644 --- a/packages/documentator/src/Utils/MarkdownDocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -1,6 +1,6 @@ getTitle(), ); self::assertNull( - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' fsdfsdfsdf @@ -31,7 +31,7 @@ public function testGetTitle(): void { ->getTitle(), ); self::assertNull( - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' # @@ -42,7 +42,7 @@ public function testGetTitle(): void { ); self::assertEquals( 'Header', - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' # Header @@ -54,7 +54,7 @@ public function testGetTitle(): void { ); self::assertEquals( 'Header', - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' @@ -69,7 +69,7 @@ public function testGetTitle(): void { public function testGetSummary(): void { self::assertNull( - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' ## Header A # Header B @@ -80,7 +80,7 @@ public function testGetSummary(): void { ->getSummary(), ); self::assertNull( - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' fsdfsdfsdf @@ -92,7 +92,7 @@ public function testGetSummary(): void { ->getSummary(), ); self::assertNull( - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' # Header @@ -105,7 +105,7 @@ public function testGetSummary(): void { ); self::assertEquals( 'fsdfsdfsdf', - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' # @@ -119,7 +119,7 @@ public function testGetSummary(): void { fsdfsdfsdf fsdfsdfsdf TEXT, - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' # Header @@ -135,7 +135,7 @@ public function testGetSummary(): void { fsdfsdfsdf fsdfsdfsdf TEXT, - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' @@ -153,7 +153,7 @@ public function testGetSummary(): void { public function testIsEmpty(): void { self::assertFalse( - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' fsdfsdfsdf fsdfsdfsdf @@ -178,7 +178,7 @@ public function testIsEmpty(): void { ->isEmpty(), ); self::assertTrue( - (new MarkdownDocument( + (new Document( <<<'MARKDOWN' diff --git a/packages/documentator/src/Utils/Markdown.php b/packages/documentator/src/Utils/Markdown.php index a4ce0aa5..3dec8d46 100644 --- a/packages/documentator/src/Utils/Markdown.php +++ b/packages/documentator/src/Utils/Markdown.php @@ -2,6 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Utils; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document as MarkdownDocument; use LastDragon_ru\LaraASP\Documentator\Package; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock; From 12784d7cd02a4887b0fd81936dc2a9732e2f7a89 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Sat, 27 Jul 2024 13:04:31 +0400 Subject: [PATCH 05/20] Link reference processing (general). --- .../documentator/src/Markdown/Document.php | 261 +++++++++++++++++- .../src/Markdown/DocumentTest.php | 132 +++++++++ .../documentator/src/Markdown/Reference.php | 38 +++ 3 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 packages/documentator/src/Markdown/Reference.php diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index dfd89a2d..730f050c 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -2,27 +2,48 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use ArrayIterator; use Closure; +use Iterator; +use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Core\Utils\Path; +use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock; +use League\CommonMark\Extension\CommonMark\Node\Inline\AbstractWebResource; use League\CommonMark\GithubFlavoredMarkdownConverter; use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Node\Block\Document as DocumentNode; use League\CommonMark\Node\Block\Paragraph; use League\CommonMark\Node\Node; use League\CommonMark\Parser\MarkdownParser; +use League\CommonMark\Reference\ReferenceParser; use Override; use Stringable; +use function array_combine; +use function array_fill_keys; +use function array_filter; +use function array_key_last; use function array_slice; +use function array_values; use function count; +use function end; +use function filter_var; use function implode; use function ltrim; -use function preg_split; +use function max; +use function preg_match; +use function range; +use function str_contains; use function str_ends_with; use function str_starts_with; +use function strtr; use function trim; +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_URL; + // todo(documentator): There is no way to convert AST back to Markdown yet // https://github.com/thephpleague/commonmark/issues/419 @@ -33,12 +54,13 @@ class Document implements Stringable { private array $lines; private DocumentNode $node; + private ?string $path = null; private ?string $title = null; private ?string $summary = null; - public function __construct(string $string) { - $this->node = $this->parse($string); - $this->lines = preg_split('/\R/u', $string) ?: []; + public function __construct(string $content, ?string $path = null) { + $this->setContent($content); + $this->setPath($path); } public function isEmpty(): bool { @@ -73,6 +95,104 @@ public function getSummary(): ?string { return $this->summary; } + public function getPath(): ?string { + return $this->path; + } + + /** + * Changes path and updates all relative links. + * + * Please note that links may/will be reformatted (because there is no + * information about their original form). + */ + public function setPath(?string $path): static { + // No path? + if ($this->path === null || $path === null) { + $this->path = $path ? Path::normalize($path) : null; + + return $this; + } + + // Same? + $path = Path::getPath($this->path, $path); + + if ($this->path === $path) { + return $this; + } + + // Update + $resources = $this->getRelativeResources(); + $lines = $this->lines; + $path = Path::normalize($path); + $getUrl = static function (string $url): string { + return preg_match('/\s/u', $url) + ? '<'.strtr($url, ['<' => '\\\\<', '>' => '\\\\>']).'>' + : $url; + }; + $getText = static function (string $text): string { + return strtr($text, ['[' => '\\\\[', ']' => '\\\\]']); + }; + $getTitle = static function (string $title): string { + if ($title === '') { + // no action + } elseif (!str_contains($title, '(') && !str_contains($title, ')')) { + $title = "({$title})"; + } elseif (!str_contains($title, '"')) { + $title = "\"{$title}\""; + } elseif (!str_contains($title, "'")) { + $title = "'{$title}'"; + } else { + $title = '('.strtr($title, ['(' => '\\\\(', ')' => '\\\\)']).')'; + } + + return $title; + }; + $replace = static function (array &$lines, string $text, ?int $start, ?int $end): void { + $end = Cast::toInt($end); + $start = Cast::toInt($start); + $range = range($start, $end); + $filler = array_fill_keys($range, null); + $changes = array_combine($range, Text::getLines($text) + array_values($filler)); + + foreach ($changes as $index => $string) { + $lines[$index] = $string; + } + }; + + foreach ($resources as $resource) { + if ($resource instanceof Reference) { + $origin = Path::getPath($this->path, $resource->getDestination()); + $target = $getUrl(Path::getRelativePath($path, $origin)); + $label = $getText($resource->getLabel()); + $title = $getTitle($resource->getTitle()); + $text = trim("[{$label}]: {$target} {$title}"); + + $replace($lines, $text, $resource->getStartLine(), $resource->getEndLine()); + } + } + + // Update + if ($resources) { + $this->setContent( + implode("\n", array_filter($lines, static fn ($line) => $line !== null)), + ); + } + + $this->path = $path; + + // Return + return $this; + } + + protected function setContent(string $content): static { + $this->node = $this->parse($content); + $this->lines = Text::getLines($content); + $this->title = null; + $this->summary = null; + + return $this; + } + protected function parse(string $string): DocumentNode { $converter = new GithubFlavoredMarkdownConverter(); $environment = $converter->getEnvironment(); @@ -135,4 +255,137 @@ protected function getFirstNode(?Node $node, string $class, ?Closure $filter = n public function __toString(): string { return implode("\n", $this->lines); } + + /** + * @return list + */ + private function getRelativeResources(): array { + // Collect Links/Images/etc and Lines with Reference + $lines = $this->lines; + $resources = []; + $isRelative = static function (string $target): bool { + return filter_var($target, FILTER_VALIDATE_URL, FILTER_NULL_ON_FAILURE) === null + && !str_starts_with($target, 'tel:+') // see https://www.php.net/manual/en/filter.filters.validate.php + && !str_starts_with($target, 'urn:') // see https://www.php.net/manual/en/filter.filters.validate.php + && Path::isRelative($target); + }; + + foreach ($this->node->iterator() as $node) { + // Block? + // => removes lines to find References later + if ($node instanceof AbstractBlock && !($node instanceof DocumentNode)) { + $start = $node->getStartLine(); + $end = $node->getEndLine(); + + if ($start !== null && $end !== null) { + for ($i = $start; $i <= $end; $i++) { + unset($lines[$i - 1]); + } + } + } + + // Resource? + // => we need only which are relative + // => we don't need references + if ($node instanceof AbstractWebResource) { + if (!$node->data->has('reference') && $isRelative($node->getUrl())) { + $resources[] = $node; + } + } + } + + // Determine start/end line for References + // + // Seems in league/commonmark:2.4.2 there is no Reference Node. References + // just exist in `ReferenceMap` and store in `data.reference` of Link/Image. + // So we need to find them by hand... + // + // See https://github.com/thephpleague/commonmark/discussions/1036 + + // Split lines into blocks + $block = 0; + $blocks = []; + $previous = null; + + foreach ($lines as $index => $line) { + if ($previous !== $index - 1) { + $block++; + } + + $blocks[$block][$index] = $line; + $previous = $index; + } + + unset($block); + + // Process each block to extract references/lines + $references = []; + $setEndLine = static function (array $block, Reference $reference, ?int $end = null): void { + $end = Cast::toInt($end ?? array_key_last($block)); + $end = $block[$end] === '' + ? max($reference->getStartLine(), $end - 1) + : $end; + + $reference->setEndLine($end); + }; + $getLineNumber = static function (Iterator $iterator, string $search, int $skip = 0): ?int { + $number = null; + + while ($iterator->valid()) { + if (str_starts_with(Cast::toString($iterator->current()), $search)) { + $number = $iterator->key(); + break; + } + + $iterator->next(); + } + + for ($i = 0; $i < $skip; $i++) { + $iterator->next(); + } + + return Cast::toIntNullable($number); + }; + + foreach ($blocks as $index => $block) { + $parser = new ReferenceParser(); + + foreach ($block as $line) { + $parser->parse($line); + } + + $refs = $parser->getReferences(); + $iterator = new ArrayIterator($block); + + foreach ($refs as $ref) { + $search = "[{$ref->getLabel()}]:"; + $skip = count(Text::getLines($ref->getTitle())); + $start = $getLineNumber($iterator, $search, $skip); + + if ($start !== null) { + if (isset($references[$index])) { + $setEndLine($block, end($references[$index]), $start - 1); + } + + $references[$index][] = new Reference($ref, $start, $start); + } + } + + if (isset($references[$index])) { + $setEndLine($block, end($references[$index])); + } + } + + // Filter + foreach ($references as $block) { + foreach ($block as $reference) { + if ($isRelative($reference->getDestination())) { + $resources[] = $reference; + } + } + } + + // Return + return $resources; + } } diff --git a/packages/documentator/src/Markdown/DocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php index e9b76e04..4946a834 100644 --- a/packages/documentator/src/Markdown/DocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -4,12 +4,15 @@ use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; /** * @internal */ #[CoversClass(Document::class)] final class DocumentTest extends TestCase { + // + // ========================================================================= public function testGetTitle(): void { self::assertNull( (new Document( @@ -188,4 +191,133 @@ public function testIsEmpty(): void { ->isEmpty(), ); } + + #[DataProvider('dataProviderSetPath')] + public function testSetPath(string $expected, ?string $path, string $content, ?string $target): void { + self::assertEquals($expected, (string) (new Document($content, $path))->setPath($target)); + } + // + + // + // ========================================================================= + /** + * @return array + */ + public static function dataProviderSetPath(): array { + return [ + // General + 'from `null`' => [ + <<<'MARKDOWN' + [foo]: relative/path/from "title" + MARKDOWN, + null, + <<<'MARKDOWN' + [foo]: relative/path/from "title" + MARKDOWN, + 'relative/path/to', + ], + 'to `null`' => [ + <<<'MARKDOWN' + [foo]: relative/path/from "title" + MARKDOWN, + 'relative/path/from', + <<<'MARKDOWN' + [foo]: relative/path/from "title" + MARKDOWN, + null, + ], + 'same' => [ + <<<'MARKDOWN' + [foo]: /path "title" + MARKDOWN, + '/path', + <<<'MARKDOWN' + [foo]: /path "title" + MARKDOWN, + '/path', + ], + // References + 'references' => [ + <<<'MARKDOWN' + # General + + [tel]: tel:+70000000000 "title" + [link]: ../from/file/a + [link]: ../from/file/b (title) + [title]: ../from/file/a (title) + [unused]: ../path/to/file + [mailto]: mailto:mail@example.com + [absolute]: /path/to/file 'title' + [external]: https://example.com/ + + [a]: ../from/file/a + [a]: ../from/file/b + + [b]: ../from/file/b ( + abc + 123 + ) + + [c]: ../from/file/c ( + title + ) + + # Special + + ## Title escaping + + ### can be avoided + + [title]: ../file/a "title with ( ) and with ' '" + [title]: ../file/a "title with ( ) and with ' '" + + ### cannot + + [title]: ../file/a (title with \\( \\) and with ' ' and with " ") + MARKDOWN, + '/path/from', + <<<'MARKDOWN' + # General + + [tel]: tel:+70000000000 "title" + [link]: ./file/a + [link]: file/b 'title' + [title]: <./file/a> (title) + [unused]: ../path/to/file + [mailto]: mailto:mail@example.com + [absolute]: /path/to/file 'title' + [external]: https://example.com/ + + [a]: file/a + [a]: file/b + + [b]: file/b " + abc + 123 + " + + [c]: + file/c + ( + title + ) + + # Special + + ## Title escaping + + ### can be avoided + + [title]: ../file/a "title with ( ) and with ' '" + [title]: ../file/a (title with \( \) and with ' ') + + ### cannot + + [title]: ../file/a "title with ( ) and with ' ' and with \" \"" + MARKDOWN, + '/path/to', + ], + ]; + } + // } diff --git a/packages/documentator/src/Markdown/Reference.php b/packages/documentator/src/Markdown/Reference.php new file mode 100644 index 00000000..17d2eff6 --- /dev/null +++ b/packages/documentator/src/Markdown/Reference.php @@ -0,0 +1,38 @@ +setStartLine($startLine); + $this->setEndLine($endLine); + } + + #[Override] + public function getLabel(): string { + return $this->reference->getLabel(); + } + + #[Override] + public function getDestination(): string { + return $this->reference->getDestination(); + } + + #[Override] + public function getTitle(): string { + return $this->reference->getTitle(); + } +} From 6b25c86948e4b53c7f54ec610c29c54b7d49a38f Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Mon, 29 Jul 2024 10:57:10 +0400 Subject: [PATCH 06/20] Switch to Reference parser. --- .../documentator/src/Markdown/Document.php | 172 +++++------------- .../src/Markdown/DocumentTest.php | 14 ++ .../documentator/src/Markdown/Nodes/Line.php | 16 ++ .../src/Markdown/Nodes/Locationable.php | 13 ++ .../src/Markdown/Nodes/Reference/Block.php | 69 +++++++ .../src/Markdown/Nodes/Reference/Parser.php | 79 ++++++++ .../Nodes/Reference/ParserContinue.php | 82 +++++++++ .../Markdown/Nodes/Reference/ParserStart.php | 34 ++++ .../Markdown/Nodes/Reference/ParserTest.md | 39 ++++ .../Markdown/Nodes/Reference/ParserTest.php | 33 ++++ .../Markdown/Nodes/Reference/ParserTest.xml | 127 +++++++++++++ .../src/Markdown/Nodes/Reference/Renderer.php | 65 +++++++ .../documentator/src/Markdown/Reference.php | 38 ---- 13 files changed, 616 insertions(+), 165 deletions(-) create mode 100644 packages/documentator/src/Markdown/Nodes/Line.php create mode 100644 packages/documentator/src/Markdown/Nodes/Locationable.php create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/Block.php create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/Parser.php create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/ParserTest.md create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/ParserTest.xml create mode 100644 packages/documentator/src/Markdown/Nodes/Reference/Renderer.php delete mode 100644 packages/documentator/src/Markdown/Reference.php diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index 730f050c..23e2c769 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -2,11 +2,11 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; -use ArrayIterator; use Closure; -use Iterator; -use LastDragon_ru\LaraASP\Core\Utils\Cast; use LastDragon_ru\LaraASP\Core\Utils\Path; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locationable; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\ParserStart as ReferenceStartParser; use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock; @@ -17,24 +17,17 @@ use League\CommonMark\Node\Block\Paragraph; use League\CommonMark\Node\Node; use League\CommonMark\Parser\MarkdownParser; -use League\CommonMark\Reference\ReferenceParser; use Override; use Stringable; -use function array_combine; -use function array_fill_keys; use function array_filter; -use function array_key_last; use function array_slice; -use function array_values; use function count; -use function end; use function filter_var; use function implode; use function ltrim; -use function max; +use function mb_substr; use function preg_match; -use function range; use function str_contains; use function str_ends_with; use function str_starts_with; @@ -147,15 +140,40 @@ public function setPath(?string $path): static { return $title; }; - $replace = static function (array &$lines, string $text, ?int $start, ?int $end): void { - $end = Cast::toInt($end); - $start = Cast::toInt($start); - $range = range($start, $end); - $filler = array_fill_keys($range, null); - $changes = array_combine($range, Text::getLines($text) + array_values($filler)); - - foreach ($changes as $index => $string) { - $lines[$index] = $string; + $replace = static function (array &$lines, Locationable $block, string $text): void { + // Replace lines + $last = null; + $line = null; + $text = Text::getLines($text); + $index = 0; + $number = null; + + foreach ($block->getLocation() as $location) { + $last = $location; + $number = $location->number - 1; + $line = $lines[$number] ?? ''; + $prefix = mb_substr($line, 0, $location->offset); + $suffix = $location->length + ? mb_substr($line, $location->offset + $location->length) + : ''; + + if (isset($text[$index])) { + $lines[$number] = $prefix.$text[$index].$suffix; + } else { + $lines[$number] = null; + } + + $index++; + } + + // Parser uses the empty line right after the block as an End Line. + // We should preserve it. + if ($last !== null) { + $content = mb_substr($line, $last->offset); + + if ($content === '') { + $lines[$number] = mb_substr($line, 0, $last->offset); + } } }; @@ -167,7 +185,7 @@ public function setPath(?string $path): static { $title = $getTitle($resource->getTitle()); $text = trim("[{$label}]: {$target} {$title}"); - $replace($lines, $text, $resource->getStartLine(), $resource->getEndLine()); + $replace($lines, $resource, $text); } } @@ -195,7 +213,8 @@ protected function setContent(string $content): static { protected function parse(string $string): DocumentNode { $converter = new GithubFlavoredMarkdownConverter(); - $environment = $converter->getEnvironment(); + $environment = $converter->getEnvironment() + ->addBlockStartParser(new ReferenceStartParser(), 250); $parser = new MarkdownParser($environment); return $parser->parse($string); @@ -260,8 +279,6 @@ public function __toString(): string { * @return list */ private function getRelativeResources(): array { - // Collect Links/Images/etc and Lines with Reference - $lines = $this->lines; $resources = []; $isRelative = static function (string $target): bool { return filter_var($target, FILTER_VALIDATE_URL, FILTER_NULL_ON_FAILURE) === null @@ -271,19 +288,6 @@ private function getRelativeResources(): array { }; foreach ($this->node->iterator() as $node) { - // Block? - // => removes lines to find References later - if ($node instanceof AbstractBlock && !($node instanceof DocumentNode)) { - $start = $node->getStartLine(); - $end = $node->getEndLine(); - - if ($start !== null && $end !== null) { - for ($i = $start; $i <= $end; $i++) { - unset($lines[$i - 1]); - } - } - } - // Resource? // => we need only which are relative // => we don't need references @@ -292,100 +296,14 @@ private function getRelativeResources(): array { $resources[] = $node; } } - } - - // Determine start/end line for References - // - // Seems in league/commonmark:2.4.2 there is no Reference Node. References - // just exist in `ReferenceMap` and store in `data.reference` of Link/Image. - // So we need to find them by hand... - // - // See https://github.com/thephpleague/commonmark/discussions/1036 - - // Split lines into blocks - $block = 0; - $blocks = []; - $previous = null; - - foreach ($lines as $index => $line) { - if ($previous !== $index - 1) { - $block++; - } - - $blocks[$block][$index] = $line; - $previous = $index; - } - - unset($block); - - // Process each block to extract references/lines - $references = []; - $setEndLine = static function (array $block, Reference $reference, ?int $end = null): void { - $end = Cast::toInt($end ?? array_key_last($block)); - $end = $block[$end] === '' - ? max($reference->getStartLine(), $end - 1) - : $end; - - $reference->setEndLine($end); - }; - $getLineNumber = static function (Iterator $iterator, string $search, int $skip = 0): ?int { - $number = null; - - while ($iterator->valid()) { - if (str_starts_with(Cast::toString($iterator->current()), $search)) { - $number = $iterator->key(); - break; - } - - $iterator->next(); - } - - for ($i = 0; $i < $skip; $i++) { - $iterator->next(); - } - - return Cast::toIntNullable($number); - }; - - foreach ($blocks as $index => $block) { - $parser = new ReferenceParser(); - - foreach ($block as $line) { - $parser->parse($line); - } - - $refs = $parser->getReferences(); - $iterator = new ArrayIterator($block); - - foreach ($refs as $ref) { - $search = "[{$ref->getLabel()}]:"; - $skip = count(Text::getLines($ref->getTitle())); - $start = $getLineNumber($iterator, $search, $skip); - - if ($start !== null) { - if (isset($references[$index])) { - $setEndLine($block, end($references[$index]), $start - 1); - } - - $references[$index][] = new Reference($ref, $start, $start); - } - } - if (isset($references[$index])) { - $setEndLine($block, end($references[$index])); - } - } - - // Filter - foreach ($references as $block) { - foreach ($block as $reference) { - if ($isRelative($reference->getDestination())) { - $resources[] = $reference; - } + // Reference + // => we need only which are relative + if ($node instanceof Reference && $isRelative($node->getDestination())) { + $resources[] = $node; } } - // Return return $resources; } } diff --git a/packages/documentator/src/Markdown/DocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php index 4946a834..022590a3 100644 --- a/packages/documentator/src/Markdown/DocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -274,6 +274,12 @@ public static function dataProviderSetPath(): array { ### cannot [title]: ../file/a (title with \\( \\) and with ' ' and with " ") + + ## Inside Quote + + > [quote]: ../file/a + > + > [quote]: ../from/file/b (title) MARKDOWN, '/path/from', <<<'MARKDOWN' @@ -314,6 +320,14 @@ public static function dataProviderSetPath(): array { ### cannot [title]: ../file/a "title with ( ) and with ' ' and with \" \"" + + ## Inside Quote + + > [quote]: ../file/a + > + > [quote]: + > ./file/b + > (title) MARKDOWN, '/path/to', ], diff --git a/packages/documentator/src/Markdown/Nodes/Line.php b/packages/documentator/src/Markdown/Nodes/Line.php new file mode 100644 index 00000000..d9d53ee6 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Line.php @@ -0,0 +1,16 @@ + + */ + public function getLocation(): iterable; +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Block.php b/packages/documentator/src/Markdown/Nodes/Reference/Block.php new file mode 100644 index 00000000..91e11b69 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/Block.php @@ -0,0 +1,69 @@ +reference = $reference; + + return $this; + } + + public function getOffset(): int { + return $this->offset; + } + + public function setOffset(int $offset): static { + $this->offset = $offset; + + return $this; + } + + #[Override] + public function getLabel(): string { + return $this->reference?->getLabel() ?? ''; + } + + #[Override] + public function getDestination(): string { + return $this->reference?->getDestination() ?? ''; + } + + #[Override] + public function getTitle(): string { + return $this->reference?->getTitle() ?? ''; + } + + /** + * @inheritDoc + */ + #[Override] + public function getLocation(): iterable { + // Unknown? + $start = $this->getStartLine(); + $end = $this->getEndLine(); + + if ($start === null || $end === null) { + yield from []; + + return; + } + + // Nope + for ($i = $start; $i <= $end; $i++) { + yield new Line($i, $this->getOffset(), null); + } + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Parser.php b/packages/documentator/src/Markdown/Nodes/Reference/Parser.php new file mode 100644 index 00000000..dbd1f755 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/Parser.php @@ -0,0 +1,79 @@ +parser = new ReferenceParser(); + $this->parserReferences = $class->getProperty('references'); + $this->parserState = $class->getProperty('state'); + $this->parserStateParagraph = $class->getConstant('PARAGRAPH'); + $this->parserStateStartDefinition = $class->getConstant('START_DEFINITION'); + } + + public function parse(string $line): bool { + // Parse + $this->parser->parse($line ?: "\n"); + + // Not a Reference + if ($this->hasState($this->parserStateParagraph)) { + return false; + } + + // The previous is finished and the second started + if ($this->getCount() > 0 && !$this->hasState($this->parserStateStartDefinition)) { + return false; + } + + // The previous and current finished + if ($this->getCount() > 1) { + return false; + } + + // Ok + return true; + } + + public function getReference(): ?ReferenceInterface { + $reference = null; + + foreach ($this->parser->getReferences() as $ref) { + $reference = $ref; + break; + } + + return $reference; + } + + private function hasState(mixed $state): bool { + return $this->parserState->getValue($this->parser) === $state; + } + + private function getCount(): int { + $references = $this->parserReferences->getValue($this->parser); + $count = is_array($references) || $references instanceof Countable + ? count($references) + : 0; + + return $count; + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php new file mode 100644 index 00000000..de361602 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php @@ -0,0 +1,82 @@ +block = new Block(); + $this->parser = new Parser(); + $this->offset = 0; + } + + public function start(Cursor $cursor): bool { + $this->offset = $cursor->getPosition(); + $started = $this->parse($cursor); + + return $started; + } + + #[Override] + public function getBlock(): AbstractBlock { + return $this->block; + } + + #[Override] + public function isContainer(): bool { + return false; + } + + #[Override] + public function canHaveLazyContinuationLines(): bool { + return false; + } + + #[Override] + public function canContain(AbstractBlock $childBlock): bool { + return false; + } + + #[Override] + public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue { + return $this->parse($cursor) + ? BlockContinue::at($cursor) + : BlockContinue::none(); + } + + #[Override] + public function addLine(string $line): void { + if ($line !== '') { + $this->parser->parse($line); + } + } + + #[Override] + public function closeBlock(): void { + $this->block + ->setReference($this->parser->getReference()) + ->setOffset($this->offset); + } + + private function parse(Cursor $cursor): bool { + $parsed = $this->parser->parse($cursor->getRemainder()); + + if ($parsed) { + $cursor->advanceToEnd(); + } + + return $parsed; + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php new file mode 100644 index 00000000..08abdb90 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php @@ -0,0 +1,34 @@ +getCurrentCharacter() !== '[') { + return BlockStart::none(); + } + + // Try + $parser = new ParserContinue(); + $block = $parser->start($cursor) + ? BlockStart::of($parser)->at($cursor) + : BlockStart::none(); + + return $block; + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.md b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.md new file mode 100644 index 00000000..0127d618 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.md @@ -0,0 +1,39 @@ +# Simple + + + +[simple:a]: https://example.com/ + +[simple:b]: https://example.com/ "example.com" +[simple:c]: +[simple:d]: file/b 'title' + +[simple:e]: file/b +[simple:e]: file/b + +# Multiline + +[multiline:a]: https://example.com/ " +1 +2 +3 +" + +[multiline:b]: + https://example.com/ + ( + example.com + ) + +# Inside Quote + +> [quote:a]: https://example.com/ (example.com) +> +> [quote:b]: +> https://example.com/ + +> > [quote:c]: https://example.com/ (example.com) +> > +> > [quote:d]: +> > https://example.com/ +> > "example.com" diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php new file mode 100644 index 00000000..3eb723de --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php @@ -0,0 +1,33 @@ +getEnvironment() + ->addBlockStartParser(new ParserStart(), 250) + ->addRenderer(Block::class, new Renderer()); + + $converter = new MarkdownToXmlConverter($environment); + + self::assertXmlStringEqualsXmlString( + self::getTestData()->content('.xml'), + (string) $converter->convert( + self::getTestData()->content('.md'), + ), + ); + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.xml b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.xml new file mode 100644 index 00000000..c7644489 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.xml @@ -0,0 +1,127 @@ + + + + Simple + + <!-- markdownlint-disable --> + + + + + + + + Multiline + + + + + Inside Quote + + + + + + + + + + + + diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php new file mode 100644 index 00000000..5156300f --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php @@ -0,0 +1,65 @@ + $this->escape($node->getLabel()), + 'destination' => $this->escape($node->getDestination()), + 'title' => $this->escape($node->getTitle()), + 'startLine' => $node->getStartLine() ?? 'null', + 'endLine' => $node->getEndLine() ?? 'null', + 'offset' => $node->getOffset(), + 'location' => $this->location($node), + ]; + } + + private function escape(string $string): string { + return preg_replace('/\R/u', '\\n', $string) ?? $string; + } + + private function location(Block $node): string { + $location = []; + + foreach ($node->getLocation() as $line) { + $location[] = '{'.implode(',', [$line->number, $line->offset, $line->length ?? 'null']).'}'; + } + + return '['.implode(',', $location).']'; + } +} diff --git a/packages/documentator/src/Markdown/Reference.php b/packages/documentator/src/Markdown/Reference.php deleted file mode 100644 index 17d2eff6..00000000 --- a/packages/documentator/src/Markdown/Reference.php +++ /dev/null @@ -1,38 +0,0 @@ -setStartLine($startLine); - $this->setEndLine($endLine); - } - - #[Override] - public function getLabel(): string { - return $this->reference->getLabel(); - } - - #[Override] - public function getDestination(): string { - return $this->reference->getDestination(); - } - - #[Override] - public function getTitle(): string { - return $this->reference->getTitle(); - } -} From 32459e0cd39f39b4f36dbf0e8f4bdff639a17eea Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:22:55 +0400 Subject: [PATCH 07/20] References will be added to `Document` as they should. --- .../documentator/src/Markdown/Document.php | 19 +++++----- .../documentator/src/Markdown/Extension.php | 38 +++++++++++++++++++ .../Nodes/Reference/ParserContinue.php | 13 ++++++- .../Markdown/Nodes/Reference/ParserStart.php | 11 +++++- .../Markdown/Nodes/Reference/ParserTest.php | 37 ++++++++++++++---- .../{ParserTest.md => ParserTest~document.md} | 0 ...ParserTest.xml => ParserTest~expected.xml} | 0 7 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 packages/documentator/src/Markdown/Extension.php rename packages/documentator/src/Markdown/Nodes/Reference/{ParserTest.md => ParserTest~document.md} (100%) rename packages/documentator/src/Markdown/Nodes/Reference/{ParserTest.xml => ParserTest~expected.xml} (100%) diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index 23e2c769..5d74eea0 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -6,7 +6,6 @@ use LastDragon_ru\LaraASP\Core\Utils\Path; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locationable; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\ParserStart as ReferenceStartParser; use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock; @@ -47,9 +46,10 @@ class Document implements Stringable { private array $lines; private DocumentNode $node; - private ?string $path = null; - private ?string $title = null; - private ?string $summary = null; + private ?MarkdownParser $parser = null; + private ?string $path = null; + private ?string $title = null; + private ?string $summary = null; public function __construct(string $content, ?string $path = null) { $this->setContent($content); @@ -212,12 +212,13 @@ protected function setContent(string $content): static { } protected function parse(string $string): DocumentNode { - $converter = new GithubFlavoredMarkdownConverter(); - $environment = $converter->getEnvironment() - ->addBlockStartParser(new ReferenceStartParser(), 250); - $parser = new MarkdownParser($environment); + if (!isset($this->parser)) { + $converter = new GithubFlavoredMarkdownConverter(); + $environment = $converter->getEnvironment()->addExtension(new Extension()); + $this->parser = new MarkdownParser($environment); + } - return $parser->parse($string); + return $this->parser->parse($string); } protected function getText(?AbstractBlock $node): ?string { diff --git a/packages/documentator/src/Markdown/Extension.php b/packages/documentator/src/Markdown/Extension.php new file mode 100644 index 00000000..c7e53493 --- /dev/null +++ b/packages/documentator/src/Markdown/Extension.php @@ -0,0 +1,38 @@ +addBlockStartParser($referenceParser) + ->addEventListener( + DocumentPreParsedEvent::class, + static function (DocumentPreParsedEvent $event) use ($referenceParser): void { + $referenceParser->setReferenceMap($event->getDocument()->getReferenceMap()); + }, + ); + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php index de361602..0e48fa99 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php @@ -6,6 +6,7 @@ use League\CommonMark\Parser\Block\BlockContinue; use League\CommonMark\Parser\Block\BlockContinueParserInterface; use League\CommonMark\Parser\Cursor; +use League\CommonMark\Reference\ReferenceMapInterface; use Override; /** @@ -16,7 +17,9 @@ class ParserContinue implements BlockContinueParserInterface { private Parser $parser; private int $offset; - public function __construct() { + public function __construct( + private readonly ?ReferenceMapInterface $referenceMap, + ) { $this->block = new Block(); $this->parser = new Parser(); $this->offset = 0; @@ -65,9 +68,15 @@ public function addLine(string $line): void { #[Override] public function closeBlock(): void { + $reference = $this->parser->getReference(); + $this->block - ->setReference($this->parser->getReference()) + ->setReference($reference) ->setOffset($this->offset); + + if ($reference && $this->referenceMap && !$this->referenceMap->contains($reference->getLabel())) { + $this->referenceMap->add($reference); + } } private function parse(Cursor $cursor): bool { diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php index 08abdb90..60bafc42 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserStart.php @@ -6,12 +6,15 @@ use League\CommonMark\Parser\Block\BlockStartParserInterface; use League\CommonMark\Parser\Cursor; use League\CommonMark\Parser\MarkdownParserStateInterface; +use League\CommonMark\Reference\ReferenceMapInterface; use Override; /** * @internal */ class ParserStart implements BlockStartParserInterface { + private ?ReferenceMapInterface $referenceMap = null; + public function __construct() { // empty } @@ -24,11 +27,17 @@ public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserSta } // Try - $parser = new ParserContinue(); + $parser = new ParserContinue($this->referenceMap); $block = $parser->start($cursor) ? BlockStart::of($parser)->at($cursor) : BlockStart::none(); return $block; } + + public function setReferenceMap(?ReferenceMapInterface $referenceMap): static { + $this->referenceMap = $referenceMap; + + return $this; + } } diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php index 3eb723de..3f0942e0 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php @@ -2,9 +2,11 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Extension; use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; use League\CommonMark\GithubFlavoredMarkdownConverter; -use League\CommonMark\Xml\MarkdownToXmlConverter; +use League\CommonMark\Parser\MarkdownParser; +use League\CommonMark\Xml\XmlRenderer; use PHPUnit\Framework\Attributes\CoversClass; /** @@ -18,16 +20,37 @@ final class ParserTest extends TestCase { public function testParse(): void { $converter = new GithubFlavoredMarkdownConverter(); $environment = $converter->getEnvironment() - ->addBlockStartParser(new ParserStart(), 250) + ->addExtension(new Extension()) ->addRenderer(Block::class, new Renderer()); - $converter = new MarkdownToXmlConverter($environment); + $parser = new MarkdownParser($environment); + $document = $parser->parse(self::getTestData()->content('~document.md')); + $renderer = new XmlRenderer($environment); + $references = []; + foreach ($document->getReferenceMap() as $label => $reference) { + $references[$label] = $reference->getLabel(); + } + + self::assertEquals( + [ + 'simple:a' => 'simple:a', + 'simple:b' => 'simple:b', + 'simple:c' => 'simple:c', + 'simple:d' => 'simple:d', + 'simple:e' => 'simple:e', + 'multiline:a' => 'multiline:a', + 'multiline:b' => 'multiline:b', + 'quote:a' => 'quote:a', + 'quote:b' => 'quote:b', + 'quote:c' => 'quote:c', + 'quote:d' => 'quote:d', + ], + $references, + ); self::assertXmlStringEqualsXmlString( - self::getTestData()->content('.xml'), - (string) $converter->convert( - self::getTestData()->content('.md'), - ), + self::getTestData()->content('~expected.xml'), + $renderer->renderDocument($document)->getContent(), ); } } diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.md b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~document.md similarity index 100% rename from packages/documentator/src/Markdown/Nodes/Reference/ParserTest.md rename to packages/documentator/src/Markdown/Nodes/Reference/ParserTest~document.md diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.xml b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml similarity index 100% rename from packages/documentator/src/Markdown/Nodes/Reference/ParserTest.xml rename to packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml From 9cfc7255cacd6ce000dc8f7a1187f22314b05e35 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:03:39 +0400 Subject: [PATCH 08/20] Base class for Renderers. --- .../Nodes/Reference/ParserTest~expected.xml | 24 ++++++------ .../src/Markdown/Nodes/Reference/Renderer.php | 32 ++------------- .../documentator/src/Markdown/XmlRenderer.php | 39 +++++++++++++++++++ 3 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 packages/documentator/src/Markdown/XmlRenderer.php diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml index c7644489..28ae883d 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml @@ -4,7 +4,7 @@ Simple <!-- markdownlint-disable --> - - - - - - Multiline - - Inside Quote - - - - $this->location($node), ]; } - - private function escape(string $string): string { - return preg_replace('/\R/u', '\\n', $string) ?? $string; - } - - private function location(Block $node): string { - $location = []; - - foreach ($node->getLocation() as $line) { - $location[] = '{'.implode(',', [$line->number, $line->offset, $line->length ?? 'null']).'}'; - } - - return '['.implode(',', $location).']'; - } } diff --git a/packages/documentator/src/Markdown/XmlRenderer.php b/packages/documentator/src/Markdown/XmlRenderer.php new file mode 100644 index 00000000..8c53d1ec --- /dev/null +++ b/packages/documentator/src/Markdown/XmlRenderer.php @@ -0,0 +1,39 @@ +getLocation() as $line) { + $location[] = '{'.implode(',', [$line->number, $line->offset, $line->length ?? 'null']).'}'; + } + } + + return '['.implode(',', $location).']'; + } +} From 4916fe29a40710881e3eb36f6b900e47fa8994e6 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Wed, 31 Jul 2024 09:40:49 +0400 Subject: [PATCH 09/20] Lines will be stored inside `Document`. --- .../documentator/src/Markdown/Data/Lines.php | 34 +++++++++++++++++++ .../documentator/src/Markdown/Document.php | 24 ++++++++----- .../documentator/src/Markdown/Extension.php | 3 ++ .../src/Markdown/ExtensionTest.php | 32 +++++++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 packages/documentator/src/Markdown/Data/Lines.php create mode 100644 packages/documentator/src/Markdown/ExtensionTest.php diff --git a/packages/documentator/src/Markdown/Data/Lines.php b/packages/documentator/src/Markdown/Data/Lines.php new file mode 100644 index 00000000..eaca0214 --- /dev/null +++ b/packages/documentator/src/Markdown/Data/Lines.php @@ -0,0 +1,34 @@ +|null + */ + private ?array $lines = null; + + public function __construct( + private readonly MarkdownInputInterface $input, + ) { + // empty + } + + /** + * @return array + */ + public function get(): array { + if ($this->lines === null) { + $this->lines = iterator_to_array($this->input->getLines()); + } + + return $this->lines; + } +} diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index 5d74eea0..f0775490 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -4,6 +4,7 @@ use Closure; use LastDragon_ru\LaraASP\Core\Utils\Path; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locationable; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; use LastDragon_ru\LaraASP\Documentator\Utils\Text; @@ -40,10 +41,6 @@ // https://github.com/thephpleague/commonmark/issues/419 class Document implements Stringable { - /** - * @var array - */ - private array $lines; private DocumentNode $node; private ?MarkdownParser $parser = null; @@ -115,7 +112,7 @@ public function setPath(?string $path): static { // Update $resources = $this->getRelativeResources(); - $lines = $this->lines; + $lines = $this->getLines(); $path = Path::normalize($path); $getUrl = static function (string $url): string { return preg_match('/\s/u', $url) @@ -150,7 +147,7 @@ public function setPath(?string $path): static { foreach ($block->getLocation() as $location) { $last = $location; - $number = $location->number - 1; + $number = $location->number; $line = $lines[$number] ?? ''; $prefix = mb_substr($line, 0, $location->offset); $suffix = $location->length @@ -204,7 +201,6 @@ public function setPath(?string $path): static { protected function setContent(string $content): static { $this->node = $this->parse($content); - $this->lines = Text::getLines($content); $this->title = null; $this->summary = null; @@ -221,6 +217,16 @@ protected function parse(string $string): DocumentNode { return $this->parser->parse($string); } + /** + * @return array + */ + protected function getLines(): array { + $lines = $this->node->data->get(Lines::class, null); + $lines = $lines instanceof Lines ? $lines->get() : []; + + return $lines; + } + protected function getText(?AbstractBlock $node): ?string { if ($node?->getStartLine() === null || $node->getEndLine() === null) { return null; @@ -228,7 +234,7 @@ protected function getText(?AbstractBlock $node): ?string { $start = $node->getStartLine() - 1; $end = $node->getEndLine() - 1; - $lines = array_slice($this->lines, $start, $end - $start + 1); + $lines = array_slice($this->getLines(), $start, $end - $start + 1); $text = implode("\n", $lines); return $text; @@ -273,7 +279,7 @@ protected function getFirstNode(?Node $node, string $class, ?Closure $filter = n #[Override] public function __toString(): string { - return implode("\n", $this->lines); + return implode("\n", $this->getLines()); } /** diff --git a/packages/documentator/src/Markdown/Extension.php b/packages/documentator/src/Markdown/Extension.php index c7e53493..618db20b 100644 --- a/packages/documentator/src/Markdown/Extension.php +++ b/packages/documentator/src/Markdown/Extension.php @@ -2,6 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Line; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\ParserStart as ReferenceParser; use League\CommonMark\Environment\EnvironmentBuilderInterface; @@ -31,6 +32,8 @@ public function register(EnvironmentBuilderInterface $environment): void { ->addEventListener( DocumentPreParsedEvent::class, static function (DocumentPreParsedEvent $event) use ($referenceParser): void { + $event->getDocument()->data->set(Lines::class, new Lines($event->getMarkdown())); + $referenceParser->setReferenceMap($event->getDocument()->getReferenceMap()); }, ); diff --git a/packages/documentator/src/Markdown/ExtensionTest.php b/packages/documentator/src/Markdown/ExtensionTest.php new file mode 100644 index 00000000..52574bbd --- /dev/null +++ b/packages/documentator/src/Markdown/ExtensionTest.php @@ -0,0 +1,32 @@ +getEnvironment() + ->addExtension(new Extension()) + ->addRenderer(Block::class, new Renderer()); + + $parser = new MarkdownParser($environment); + $markdown = "# Header\nParagraph."; + $document = $parser->parse($markdown); + $lines = $document->data->get(Lines::class, null); + + self::assertInstanceOf(Lines::class, $lines); + self::assertCount(2, $lines->get()); + } +} From 649d39e478104d1b587a7563b8d8ab389b4a9e07 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Wed, 31 Jul 2024 10:41:44 +0400 Subject: [PATCH 10/20] Better `Location`. --- .../src/Markdown/Data/Coordinate.php | 16 +++++++ .../src/Markdown/Data/Location.php | 30 +++++++++++++ .../documentator/src/Markdown/Document.php | 33 +++++++------- .../documentator/src/Markdown/Extension.php | 4 +- .../documentator/src/Markdown/Nodes/Line.php | 16 ------- .../src/Markdown/Nodes/Locationable.php | 13 ------ .../src/Markdown/Nodes/Locator.php | 44 +++++++++++++++++++ .../src/Markdown/Nodes/Reference/Block.php | 30 +++++-------- .../Nodes/Reference/ParserContinue.php | 14 +++--- .../Nodes/Reference/ParserTest~expected.xml | 24 +++++----- .../src/Markdown/Nodes/Reference/Renderer.php | 4 +- .../documentator/src/Markdown/XmlRenderer.php | 14 +++--- 12 files changed, 148 insertions(+), 94 deletions(-) create mode 100644 packages/documentator/src/Markdown/Data/Coordinate.php create mode 100644 packages/documentator/src/Markdown/Data/Location.php delete mode 100644 packages/documentator/src/Markdown/Nodes/Line.php delete mode 100644 packages/documentator/src/Markdown/Nodes/Locationable.php create mode 100644 packages/documentator/src/Markdown/Nodes/Locator.php diff --git a/packages/documentator/src/Markdown/Data/Coordinate.php b/packages/documentator/src/Markdown/Data/Coordinate.php new file mode 100644 index 00000000..80a9823f --- /dev/null +++ b/packages/documentator/src/Markdown/Data/Coordinate.php @@ -0,0 +1,16 @@ + + */ +class Location implements IteratorAggregate { + public function __construct( + /** + * @var iterable + */ + private readonly iterable $coordinates, + ) { + // empty + } + + /** + * @return Traversable + */ + #[Override] + public function getIterator(): Traversable { + yield from $this->coordinates; + } +} diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index f0775490..11d128a4 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -5,7 +5,7 @@ use Closure; use LastDragon_ru\LaraASP\Core\Utils\Path; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locationable; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; @@ -137,7 +137,7 @@ public function setPath(?string $path): static { return $title; }; - $replace = static function (array &$lines, Locationable $block, string $text): void { + $replace = static function (array &$lines, Location $location, string $text): void { // Replace lines $last = null; $line = null; @@ -145,13 +145,13 @@ public function setPath(?string $path): static { $index = 0; $number = null; - foreach ($block->getLocation() as $location) { - $last = $location; - $number = $location->number; + foreach ($location as $coordinate) { + $last = $coordinate; + $number = $coordinate->line; $line = $lines[$number] ?? ''; - $prefix = mb_substr($line, 0, $location->offset); - $suffix = $location->length - ? mb_substr($line, $location->offset + $location->length) + $prefix = mb_substr($line, 0, $coordinate->offset); + $suffix = $coordinate->length + ? mb_substr($line, $coordinate->offset + $coordinate->length) : ''; if (isset($text[$index])) { @@ -176,13 +176,16 @@ public function setPath(?string $path): static { foreach ($resources as $resource) { if ($resource instanceof Reference) { - $origin = Path::getPath($this->path, $resource->getDestination()); - $target = $getUrl(Path::getRelativePath($path, $origin)); - $label = $getText($resource->getLabel()); - $title = $getTitle($resource->getTitle()); - $text = trim("[{$label}]: {$target} {$title}"); - - $replace($lines, $resource, $text); + $location = $resource->getLocation(); + $origin = Path::getPath($this->path, $resource->getDestination()); + $target = $getUrl(Path::getRelativePath($path, $origin)); + $label = $getText($resource->getLabel()); + $title = $getTitle($resource->getTitle()); + $text = trim("[{$label}]: {$target} {$title}"); + + if ($location) { + $replace($lines, $location, $text); + } } } diff --git a/packages/documentator/src/Markdown/Extension.php b/packages/documentator/src/Markdown/Extension.php index 618db20b..09326103 100644 --- a/packages/documentator/src/Markdown/Extension.php +++ b/packages/documentator/src/Markdown/Extension.php @@ -2,8 +2,8 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Line; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\ParserStart as ReferenceParser; use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Event\DocumentPreParsedEvent; @@ -18,7 +18,7 @@ * (by default, they are not added to the AST) * * @see https://github.com/thephpleague/commonmark/discussions/1036 - * @see Line + * @see Coordinate * * @internal */ diff --git a/packages/documentator/src/Markdown/Nodes/Line.php b/packages/documentator/src/Markdown/Nodes/Line.php deleted file mode 100644 index d9d53ee6..00000000 --- a/packages/documentator/src/Markdown/Nodes/Line.php +++ /dev/null @@ -1,16 +0,0 @@ - - */ - public function getLocation(): iterable; -} diff --git a/packages/documentator/src/Markdown/Nodes/Locator.php b/packages/documentator/src/Markdown/Nodes/Locator.php new file mode 100644 index 00000000..5b926b41 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator.php @@ -0,0 +1,44 @@ + + */ +readonly class Locator implements IteratorAggregate { + public function __construct( + private int $startLine, + private int $endLine, + private int $offset, + private ?int $length = null, + private int $padding = 0, + ) { + // empty + } + + /** + * @return Traversable + */ + #[Override] + public function getIterator(): Traversable { + if ($this->startLine === $this->endLine) { + yield new Coordinate($this->startLine, $this->offset, $this->length); + } else { + for ($line = $this->startLine; $line <= $this->endLine; $line++) { + yield match (true) { + $line === $this->startLine => new Coordinate($line, $this->padding + $this->offset, null), + $line === $this->endLine => new Coordinate($line, $this->padding, $this->length), + default => new Coordinate($line, $this->padding, null), + }; + } + } + + yield from []; + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Block.php b/packages/documentator/src/Markdown/Nodes/Reference/Block.php index 91e11b69..0b5309d2 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/Block.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/Block.php @@ -2,8 +2,8 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Line; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locationable; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Reference\ReferenceInterface; use Override; @@ -11,9 +11,9 @@ /** * @internal */ -class Block extends AbstractBlock implements ReferenceInterface, Locationable { +class Block extends AbstractBlock implements ReferenceInterface { private ?ReferenceInterface $reference = null; - private int $offset = 0; + private int $padding = 0; public function setReference(?ReferenceInterface $reference): static { $this->reference = $reference; @@ -21,12 +21,12 @@ public function setReference(?ReferenceInterface $reference): static { return $this; } - public function getOffset(): int { - return $this->offset; + public function getPadding(): int { + return $this->padding; } - public function setOffset(int $offset): static { - $this->offset = $offset; + public function setPadding(int $padding): static { + $this->padding = $padding; return $this; } @@ -46,24 +46,16 @@ public function getTitle(): string { return $this->reference?->getTitle() ?? ''; } - /** - * @inheritDoc - */ - #[Override] - public function getLocation(): iterable { + public function getLocation(): ?Location { // Unknown? $start = $this->getStartLine(); $end = $this->getEndLine(); if ($start === null || $end === null) { - yield from []; - - return; + return null; } // Nope - for ($i = $start; $i <= $end; $i++) { - yield new Line($i, $this->getOffset(), null); - } + return new Location(new Locator($start, $end, 0, null, $this->getPadding())); } } diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php index 0e48fa99..7fa231d2 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php @@ -15,19 +15,19 @@ class ParserContinue implements BlockContinueParserInterface { private Block $block; private Parser $parser; - private int $offset; + private int $padding; public function __construct( private readonly ?ReferenceMapInterface $referenceMap, ) { - $this->block = new Block(); - $this->parser = new Parser(); - $this->offset = 0; + $this->block = new Block(); + $this->parser = new Parser(); + $this->padding = 0; } public function start(Cursor $cursor): bool { - $this->offset = $cursor->getPosition(); - $started = $this->parse($cursor); + $this->padding = $cursor->getPosition(); + $started = $this->parse($cursor); return $started; } @@ -72,7 +72,7 @@ public function closeBlock(): void { $this->block ->setReference($reference) - ->setOffset($this->offset); + ->setPadding($this->padding); if ($reference && $this->referenceMap && !$this->referenceMap->contains($reference->getLabel())) { $this->referenceMap->add($reference); diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml index 28ae883d..08c0f131 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml @@ -10,7 +10,7 @@ title="" startLine="5" endLine="6" - offset="0" + padding="0" location="[{5,0,null},{6,0,null}]" /> @@ -67,7 +67,7 @@ title="\n1\n2\n3\n" startLine="16" endLine="21" - offset="0" + padding="0" location="[{16,0,null},{17,0,null},{18,0,null},{19,0,null},{20,0,null},{21,0,null}]" /> @@ -89,7 +89,7 @@ title="example.com" startLine="30" endLine="31" - offset="2" + padding="2" location="[{30,2,null},{31,2,null}]" /> @@ -110,7 +110,7 @@ title="example.com" startLine="35" endLine="36" - offset="4" + padding="4" location="[{35,4,null},{36,4,null}]" /> diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php index 1a62f387..4ef16a1f 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php @@ -32,8 +32,8 @@ public function getXmlAttributes(Node $node): array { 'title' => $this->escape($node->getTitle()), 'startLine' => $node->getStartLine() ?? 'null', 'endLine' => $node->getEndLine() ?? 'null', - 'offset' => $node->getOffset(), - 'location' => $this->location($node), + 'padding' => $node->getPadding(), + 'location' => $this->location($node->getLocation()), ]; } } diff --git a/packages/documentator/src/Markdown/XmlRenderer.php b/packages/documentator/src/Markdown/XmlRenderer.php index 8c53d1ec..808e8192 100644 --- a/packages/documentator/src/Markdown/XmlRenderer.php +++ b/packages/documentator/src/Markdown/XmlRenderer.php @@ -2,7 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locationable; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use League\CommonMark\Node\Node; use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface; @@ -25,15 +25,13 @@ protected function escape(string $string): string { return preg_replace('/\R/u', '\\n', $string) ?? $string; } - protected function location(?Locationable $node): string { - $location = []; + protected function location(?Location $location): string { + $lines = []; - if ($node instanceof Locationable) { - foreach ($node->getLocation() as $line) { - $location[] = '{'.implode(',', [$line->number, $line->offset, $line->length ?? 'null']).'}'; - } + foreach ($location ?? [] as $line) { + $lines[] = '{'.implode(',', [$line->line, $line->offset, $line->length ?? 'null']).'}'; } - return '['.implode(',', $location).']'; + return '['.implode(',', $lines).']'; } } From 9e6b56afa9fd3217a5f77cb47a439d4bfa15c7ac Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Wed, 31 Jul 2024 14:37:01 +0400 Subject: [PATCH 11/20] Inline nodes location detection. --- .../src/Markdown/Data/Padding.php | 14 ++ .../documentator/src/Markdown/Extension.php | 3 + .../src/Markdown/ExtensionTest.php | 19 +- .../src/Markdown/Nodes/Locator.php | 2 +- .../src/Markdown/Nodes/Locator/Parser.php | 192 ++++++++++++++++++ .../src/Markdown/Nodes/Locator/ParserTest.php | 37 ++++ .../Nodes/Locator/ParserTest~document.md | 27 +++ .../Nodes/Locator/ParserTest~expected.xml | 116 +++++++++++ .../src/Markdown/Nodes/Locator/Renderer.php | 40 ++++ .../documentator/src/Markdown/XmlRenderer.php | 4 +- 10 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 packages/documentator/src/Markdown/Data/Padding.php create mode 100644 packages/documentator/src/Markdown/Nodes/Locator/Parser.php create mode 100644 packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php create mode 100644 packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md create mode 100644 packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml create mode 100644 packages/documentator/src/Markdown/Nodes/Locator/Renderer.php diff --git a/packages/documentator/src/Markdown/Data/Padding.php b/packages/documentator/src/Markdown/Data/Padding.php new file mode 100644 index 00000000..e4eb875e --- /dev/null +++ b/packages/documentator/src/Markdown/Data/Padding.php @@ -0,0 +1,14 @@ +addBlockStartParser($referenceParser) + ->addInlineParser(new Parser(new CloseBracketParser()), 100) ->addEventListener( DocumentPreParsedEvent::class, static function (DocumentPreParsedEvent $event) use ($referenceParser): void { diff --git a/packages/documentator/src/Markdown/ExtensionTest.php b/packages/documentator/src/Markdown/ExtensionTest.php index 52574bbd..9dc01d8a 100644 --- a/packages/documentator/src/Markdown/ExtensionTest.php +++ b/packages/documentator/src/Markdown/ExtensionTest.php @@ -2,14 +2,21 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Renderer; use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\GithubFlavoredMarkdownConverter; +use League\CommonMark\Node\Query; use League\CommonMark\Parser\MarkdownParser; use PHPUnit\Framework\Attributes\CoversClass; +use function iterator_to_array; + /** * @internal */ @@ -22,11 +29,21 @@ public function testExtension(): void { ->addRenderer(Block::class, new Renderer()); $parser = new MarkdownParser($environment); - $markdown = "# Header\nParagraph."; + $markdown = "# Header\nParagraph [link](https://example.com/)."; $document = $parser->parse($markdown); $lines = $document->data->get(Lines::class, null); + $link = (new Query())->where(Query::type(Link::class))->findOne($document); self::assertInstanceOf(Lines::class, $lines); self::assertCount(2, $lines->get()); + self::assertNotNull($link); + self::assertEquals( + [ + new Coordinate(2, 10, 28), + ], + iterator_to_array( + Cast::toIterable($link->data->get(Location::class, null) ?? []), + ), + ); } } diff --git a/packages/documentator/src/Markdown/Nodes/Locator.php b/packages/documentator/src/Markdown/Nodes/Locator.php index 5b926b41..21b7e4ce 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator.php +++ b/packages/documentator/src/Markdown/Nodes/Locator.php @@ -28,7 +28,7 @@ public function __construct( #[Override] public function getIterator(): Traversable { if ($this->startLine === $this->endLine) { - yield new Coordinate($this->startLine, $this->offset, $this->length); + yield new Coordinate($this->startLine, $this->padding + $this->offset, $this->length); } else { for ($line = $this->startLine; $line <= $this->endLine; $line++) { yield match (true) { diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php new file mode 100644 index 00000000..10142c9a --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php @@ -0,0 +1,192 @@ +parser->getMatchDefinition(); + } + + #[Override] + public function parse(InlineParserContext $inlineContext): bool { + // The `$cursor->getPosition()` depends on delimiters length, we need to + // find it. Not sure that this is the best way... + $cursor = $inlineContext->getCursor(); + $offset = $cursor->getPosition() + - $this->getDelimiterStackLength($inlineContext->getDelimiterStack()) // delimiters length + - mb_strlen($cursor->getPreviousText()); // text after delimiter + $parsed = $this->parser->parse($inlineContext); + + if ($parsed) { + $container = $inlineContext->getContainer(); + $startLine = $container->getStartLine(); + $endLine = $container->getEndLine(); + $child = $container->lastChild(); + + if ($child !== null && $startLine !== null && $endLine !== null) { + $length = $cursor->getPosition() - $offset; + $line = $cursor->getLine(); + $tail = $line; + + if ($startLine !== $endLine) { + $before = mb_substr($line, 0, $offset); + $beforeLines = Text::getLines($before); + $beforeLinesCount = count($beforeLines) - 1; + $inline = mb_substr($line, $offset, $length); + $inlineLines = Text::getLines($inline); + $inlineLinesCount = count($inlineLines) - 1; + $startLine = $startLine + $beforeLinesCount; + $endLine = $startLine + $inlineLinesCount; + $tail = (end($beforeLines) ?: '').(reset($inlineLines) ?: ''); + + if ($beforeLinesCount) { + $offset -= (mb_strlen(implode("\n", array_slice($beforeLines, 0, -1))) + 1); + } + + if ($startLine !== $endLine) { + $length -= mb_strlen($inline); + } + } + + $padding = $this->getBlockPadding($child, $startLine, $tail); + + if ($padding !== null) { + $child->data->set( + Location::class, + new Location( + new Locator($startLine, $endLine, $offset, $length, $padding), + ), + ); + } + } + } + + return $parsed; + } + + #[Override] + public function setEnvironment(EnvironmentInterface $environment): void { + if ($this->parser instanceof EnvironmentAwareInterface) { + $this->parser->setEnvironment($environment); + } + } + + #[Override] + public function setConfiguration(ConfigurationInterface $configuration): void { + if ($this->parser instanceof ConfigurationAwareInterface) { + $this->parser->setConfiguration($configuration); + } + } + + private function getDelimiterStackLength(DelimiterStack $stack): int { + $delimiter = (new ReflectionProperty($stack, 'top'))->getValue($stack); + $length = 0; + + if ($delimiter instanceof DelimiterInterface) { + $length += $delimiter->getLength(); + } + + return $length; + } + + private function getBlockPadding(Node $node, int $line, string $tail): ?int { + // Search for Document + $document = null; + $padding = null; + $parent = $node; + $block = null; + + do { + // Document? + if ($parent instanceof Document) { + $document = $parent; + break; + } + + // Cached? + if ($parent instanceof AbstractBlock && $block === null) { + $block = $parent; + + if ($block->data->has(Padding::class)) { + $padding = Cast::toNullable(Padding::class, $block->data->get(Padding::class, null))?->value; + + if ($padding !== null) { + break; + } + } + } + + // Deep + $parent = $parent->parent(); + } while ($parent); + + if ($document === null) { + return $padding; + } + + // Detect block padding + // (we are expecting that all lines inside block have the same padding) + $lines = $document->data->get(Lines::class, null); + $lines = $lines instanceof Lines ? $lines->get() : []; + $padding = mb_strpos($lines[$line] ?? '', $tail); + + if ($padding === false) { + return null; + } + + // Cache + if ($block) { + $block->data->set(Padding::class, new Padding($padding)); + } + + // Return + return $padding; + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php new file mode 100644 index 00000000..fd99e467 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php @@ -0,0 +1,37 @@ +getEnvironment() + ->addExtension(new Extension()) + ->addRenderer(Link::class, new Renderer()) + ->addRenderer(ReferenceNode::class, new ReferenceRenderer()); + + $parser = new MarkdownParser($environment); + $document = $parser->parse(self::getTestData()->content('~document.md')); + $renderer = new XmlRenderer($environment); + + self::assertXmlStringEqualsXmlString( + self::getTestData()->content('~expected.xml'), + $renderer->renderDocument($document)->getContent(), + ); + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md new file mode 100644 index 00000000..b0fd19b7 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md @@ -0,0 +1,27 @@ +# Simple + + + +Text text _**[link](https://example.com/)**_. + +Text text [link](https://example.com/) text [link](https://example.com/ "title") text +text text text text [link][link] text text [link](https://example.com/) text text text text text text +text text text text text text text text text text text text text text text text text +text text _[link](https://example.com/)_ text. + +[link]: https://example.com/ "reference" + +# Lists + +* List list [link](https://example.com/). + * List list [link](https://example.com/). + * List list [link](https://example.com/). + +# Quotes + +> Quote quote [link](https://example.com/). +> +> Quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote +> quote quote [link](https://example.com/) quote. + +> > Quote quote [link](https://example.com/). diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml new file mode 100644 index 00000000..f122d024 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml @@ -0,0 +1,116 @@ + + + + Simple + + <!-- markdownlint-disable --> + + + + + + link + + + + . + + + + + link + + + + link + + + + + + link + + + + link + + + + text text text text text text text text text text text text text text text text text + + + + + link + + + + + + + Lists + + + + + + + link + + . + + + + + + + link + + . + + + + + + + link + + . + + + + + + + Quotes + + + + + + link + + . + + + Quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote + + + + link + + + + + + + + + + link + + . + + + + diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php b/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php new file mode 100644 index 00000000..cad171d1 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php @@ -0,0 +1,40 @@ + $this->escape($node->getUrl()), + 'title' => $this->escape($node->getTitle()), + 'location' => $this->location( + Cast::toNullable(Location::class, $node->data->get(Location::class, null)), + ), + ]; + } +} diff --git a/packages/documentator/src/Markdown/XmlRenderer.php b/packages/documentator/src/Markdown/XmlRenderer.php index 808e8192..35aceb7e 100644 --- a/packages/documentator/src/Markdown/XmlRenderer.php +++ b/packages/documentator/src/Markdown/XmlRenderer.php @@ -21,8 +21,8 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer): s return ''; } - protected function escape(string $string): string { - return preg_replace('/\R/u', '\\n', $string) ?? $string; + protected function escape(?string $string): string { + return preg_replace('/\R/u', '\\n', $string ?? '') ?? $string ?? ''; } protected function location(?Location $location): string { From 239b04a0ce9152a1b5b7a61f81e97c83f5d03f02 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Thu, 1 Aug 2024 10:05:02 +0400 Subject: [PATCH 12/20] Better approach to work with `Node::$data`. --- packages/documentator/src/Markdown/Data.php | 50 +++++++++++++++++++ .../documentator/src/Markdown/Data/Lines.php | 7 ++- .../src/Markdown/Data/Location.php | 19 +++---- .../src/Markdown/Data/Padding.php | 12 ++++- .../documentator/src/Markdown/Data/Value.php | 14 ++++++ .../documentator/src/Markdown/Document.php | 10 ++-- .../documentator/src/Markdown/Extension.php | 4 +- .../src/Markdown/ExtensionTest.php | 11 ++-- .../{Data => Location}/Coordinate.php | 2 +- .../src/Markdown/Location/Location.php | 12 +++++ .../Markdown/{Nodes => Location}/Locator.php | 7 +-- .../src/Markdown/Nodes/Locator/Parser.php | 27 ++++------ .../Nodes/Locator/ParserTest~expected.xml | 2 +- .../src/Markdown/Nodes/Locator/Renderer.php | 6 +-- .../src/Markdown/Nodes/Reference/Block.php | 26 ---------- .../Nodes/Reference/ParserContinue.php | 19 +++++-- .../Nodes/Reference/ParserTest~expected.xml | 36 ------------- .../src/Markdown/Nodes/Reference/Renderer.php | 5 +- .../documentator/src/Markdown/XmlRenderer.php | 7 +-- 19 files changed, 143 insertions(+), 133 deletions(-) create mode 100644 packages/documentator/src/Markdown/Data.php create mode 100644 packages/documentator/src/Markdown/Data/Value.php rename packages/documentator/src/Markdown/{Data => Location}/Coordinate.php (78%) create mode 100644 packages/documentator/src/Markdown/Location/Location.php rename packages/documentator/src/Markdown/{Nodes => Location}/Locator.php (81%) diff --git a/packages/documentator/src/Markdown/Data.php b/packages/documentator/src/Markdown/Data.php new file mode 100644 index 00000000..90b5dd51 --- /dev/null +++ b/packages/documentator/src/Markdown/Data.php @@ -0,0 +1,50 @@ +> $data + * + * @return ?T + */ + public static function get(Node $node, string $data): mixed { + $value = $node->data->get($data, null); + $value = is_object($value) && is_a($value, $data, true) + ? $value->get() + : null; + + return $value; + } + + /** + * @template T + * + * @param Value $value + * + * @return T + */ + public static function set(Node $node, mixed $value): mixed { + $node->data->set($value::class, $value); + + return $value->get(); + } + + /** + * @param class-string> $data + */ + public static function remove(Node $node, string $data): void { + $node->data->remove($data); + } +} diff --git a/packages/documentator/src/Markdown/Data/Lines.php b/packages/documentator/src/Markdown/Data/Lines.php index eaca0214..af2e8d96 100644 --- a/packages/documentator/src/Markdown/Data/Lines.php +++ b/packages/documentator/src/Markdown/Data/Lines.php @@ -3,13 +3,15 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Data; use League\CommonMark\Input\MarkdownInputInterface; +use Override; use function iterator_to_array; /** + * @implements Value> * @internal */ -class Lines { +class Lines implements Value { /** * @var array|null */ @@ -22,8 +24,9 @@ public function __construct( } /** - * @return array + * @inheritDoc */ + #[Override] public function get(): array { if ($this->lines === null) { $this->lines = iterator_to_array($this->input->getLines()); diff --git a/packages/documentator/src/Markdown/Data/Location.php b/packages/documentator/src/Markdown/Data/Location.php index 33bb9994..788a1994 100644 --- a/packages/documentator/src/Markdown/Data/Location.php +++ b/packages/documentator/src/Markdown/Data/Location.php @@ -2,29 +2,22 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Data; -use IteratorAggregate; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Location as LocationContract; use Override; -use Traversable; /** * @internal - * @implements IteratorAggregate + * @implements Value */ -class Location implements IteratorAggregate { +class Location implements Value { public function __construct( - /** - * @var iterable - */ - private readonly iterable $coordinates, + private readonly LocationContract $location, ) { // empty } - /** - * @return Traversable - */ #[Override] - public function getIterator(): Traversable { - yield from $this->coordinates; + public function get(): mixed { + return $this->location; } } diff --git a/packages/documentator/src/Markdown/Data/Padding.php b/packages/documentator/src/Markdown/Data/Padding.php index e4eb875e..dd29b06e 100644 --- a/packages/documentator/src/Markdown/Data/Padding.php +++ b/packages/documentator/src/Markdown/Data/Padding.php @@ -2,13 +2,21 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Data; +use Override; + /** * @internal + * @implements Value */ -readonly class Padding { +readonly class Padding implements Value { public function __construct( - public int $value, + private int $value, ) { // empty } + + #[Override] + public function get(): mixed { + return $this->value; + } } diff --git a/packages/documentator/src/Markdown/Data/Value.php b/packages/documentator/src/Markdown/Data/Value.php new file mode 100644 index 00000000..49d96545 --- /dev/null +++ b/packages/documentator/src/Markdown/Data/Value.php @@ -0,0 +1,14 @@ +getLocation(); + $location = Data::get($resource, LocationData::class); $origin = Path::getPath($this->path, $resource->getDestination()); $target = $getUrl(Path::getRelativePath($path, $origin)); $label = $getText($resource->getLabel()); @@ -224,10 +225,7 @@ protected function parse(string $string): DocumentNode { * @return array */ protected function getLines(): array { - $lines = $this->node->data->get(Lines::class, null); - $lines = $lines instanceof Lines ? $lines->get() : []; - - return $lines; + return Data::get($this->node, Lines::class) ?? []; } protected function getText(?AbstractBlock $node): ?string { diff --git a/packages/documentator/src/Markdown/Extension.php b/packages/documentator/src/Markdown/Extension.php index 4c2083b6..eac784df 100644 --- a/packages/documentator/src/Markdown/Extension.php +++ b/packages/documentator/src/Markdown/Extension.php @@ -2,8 +2,8 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator\Parser; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\ParserStart as ReferenceParser; use League\CommonMark\Environment\EnvironmentBuilderInterface; @@ -35,7 +35,7 @@ public function register(EnvironmentBuilderInterface $environment): void { ->addEventListener( DocumentPreParsedEvent::class, static function (DocumentPreParsedEvent $event) use ($referenceParser): void { - $event->getDocument()->data->set(Lines::class, new Lines($event->getMarkdown())); + Data::set($event->getDocument(), new Lines($event->getMarkdown())); $referenceParser->setReferenceMap($event->getDocument()->getReferenceMap()); }, diff --git a/packages/documentator/src/Markdown/ExtensionTest.php b/packages/documentator/src/Markdown/ExtensionTest.php index 9dc01d8a..67089001 100644 --- a/packages/documentator/src/Markdown/ExtensionTest.php +++ b/packages/documentator/src/Markdown/ExtensionTest.php @@ -2,10 +2,9 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; -use LastDragon_ru\LaraASP\Core\Utils\Cast; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Renderer; use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; @@ -31,18 +30,18 @@ public function testExtension(): void { $parser = new MarkdownParser($environment); $markdown = "# Header\nParagraph [link](https://example.com/)."; $document = $parser->parse($markdown); - $lines = $document->data->get(Lines::class, null); + $lines = Data::get($document, Lines::class); $link = (new Query())->where(Query::type(Link::class))->findOne($document); - self::assertInstanceOf(Lines::class, $lines); - self::assertCount(2, $lines->get()); + self::assertIsArray($lines); + self::assertCount(2, $lines); self::assertNotNull($link); self::assertEquals( [ new Coordinate(2, 10, 28), ], iterator_to_array( - Cast::toIterable($link->data->get(Location::class, null) ?? []), + Data::get($link, Location::class) ?? [], ), ); } diff --git a/packages/documentator/src/Markdown/Data/Coordinate.php b/packages/documentator/src/Markdown/Location/Coordinate.php similarity index 78% rename from packages/documentator/src/Markdown/Data/Coordinate.php rename to packages/documentator/src/Markdown/Location/Coordinate.php index 80a9823f..9401f82c 100644 --- a/packages/documentator/src/Markdown/Data/Coordinate.php +++ b/packages/documentator/src/Markdown/Location/Coordinate.php @@ -1,6 +1,6 @@ + */ +interface Location extends IteratorAggregate { + // empty +} diff --git a/packages/documentator/src/Markdown/Nodes/Locator.php b/packages/documentator/src/Markdown/Location/Locator.php similarity index 81% rename from packages/documentator/src/Markdown/Nodes/Locator.php rename to packages/documentator/src/Markdown/Location/Locator.php index 21b7e4ce..d31e77f6 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator.php +++ b/packages/documentator/src/Markdown/Location/Locator.php @@ -1,17 +1,14 @@ */ -readonly class Locator implements IteratorAggregate { +readonly class Locator implements Location { public function __construct( private int $startLine, private int $endLine, diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php index 10142c9a..f888e679 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php @@ -2,11 +2,11 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; -use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Padding; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Delimiter\DelimiterInterface; use League\CommonMark\Delimiter\DelimiterStack; @@ -98,12 +98,7 @@ public function parse(InlineParserContext $inlineContext): bool { $padding = $this->getBlockPadding($child, $startLine, $tail); if ($padding !== null) { - $child->data->set( - Location::class, - new Location( - new Locator($startLine, $endLine, $offset, $length, $padding), - ), - ); + Data::set($child, new Location(new Locator($startLine, $endLine, $offset, $length, $padding))); } } } @@ -152,14 +147,11 @@ private function getBlockPadding(Node $node, int $line, string $tail): ?int { // Cached? if ($parent instanceof AbstractBlock && $block === null) { - $block = $parent; + $block = $parent; + $padding = Data::get($block, Padding::class); - if ($block->data->has(Padding::class)) { - $padding = Cast::toNullable(Padding::class, $block->data->get(Padding::class, null))?->value; - - if ($padding !== null) { - break; - } + if ($padding !== null) { + break; } } @@ -173,8 +165,7 @@ private function getBlockPadding(Node $node, int $line, string $tail): ?int { // Detect block padding // (we are expecting that all lines inside block have the same padding) - $lines = $document->data->get(Lines::class, null); - $lines = $lines instanceof Lines ? $lines->get() : []; + $lines = Data::get($document, Lines::class) ?? []; $padding = mb_strpos($lines[$line] ?? '', $tail); if ($padding === false) { @@ -183,7 +174,7 @@ private function getBlockPadding(Node $node, int $line, string $tail): ?int { // Cache if ($block) { - $block->data->set(Padding::class, new Padding($padding)); + Data::set($block, new Padding($padding)); } // Return diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml index f122d024..4372c8a2 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml @@ -46,7 +46,7 @@ - + Lists diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php b/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php index cad171d1..8a0a2acf 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php @@ -2,8 +2,6 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; -use LastDragon_ru\LaraASP\Core\Utils\Cast; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\XmlRenderer; use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\Node\Node; @@ -32,9 +30,7 @@ public function getXmlAttributes(Node $node): array { return [ 'url' => $this->escape($node->getUrl()), 'title' => $this->escape($node->getTitle()), - 'location' => $this->location( - Cast::toNullable(Location::class, $node->data->get(Location::class, null)), - ), + 'location' => $this->location($node), ]; } } diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Block.php b/packages/documentator/src/Markdown/Nodes/Reference/Block.php index 0b5309d2..664a4b78 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/Block.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/Block.php @@ -2,8 +2,6 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Reference\ReferenceInterface; use Override; @@ -13,7 +11,6 @@ */ class Block extends AbstractBlock implements ReferenceInterface { private ?ReferenceInterface $reference = null; - private int $padding = 0; public function setReference(?ReferenceInterface $reference): static { $this->reference = $reference; @@ -21,16 +18,6 @@ public function setReference(?ReferenceInterface $reference): static { return $this; } - public function getPadding(): int { - return $this->padding; - } - - public function setPadding(int $padding): static { - $this->padding = $padding; - - return $this; - } - #[Override] public function getLabel(): string { return $this->reference?->getLabel() ?? ''; @@ -45,17 +32,4 @@ public function getDestination(): string { public function getTitle(): string { return $this->reference?->getTitle() ?? ''; } - - public function getLocation(): ?Location { - // Unknown? - $start = $this->getStartLine(); - $end = $this->getEndLine(); - - if ($start === null || $end === null) { - return null; - } - - // Nope - return new Location(new Locator($start, $end, 0, null, $this->getPadding())); - } } diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php index 7fa231d2..35c82ae9 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php @@ -2,6 +2,10 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Padding; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Parser\Block\BlockContinue; use League\CommonMark\Parser\Block\BlockContinueParserInterface; @@ -68,15 +72,24 @@ public function addLine(string $line): void { #[Override] public function closeBlock(): void { + // Reference $reference = $this->parser->getReference(); - $this->block - ->setReference($reference) - ->setPadding($this->padding); + $this->block->setReference($reference); if ($reference && $this->referenceMap && !$this->referenceMap->contains($reference->getLabel())) { $this->referenceMap->add($reference); } + + // Data + Data::set($this->block, new Padding($this->padding)); + + $start = $this->block->getStartLine(); + $end = $this->block->getEndLine(); + + if ($start !== null && $end !== null) { + Data::set($this->block, new Location(new Locator($start, $end, 0, null, $this->padding))); + } } private function parse(Cursor $cursor): bool { diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml index 08c0f131..73e58cbd 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml @@ -8,54 +8,36 @@ label="simple:a" destination="https://example.com/" title="" - startLine="5" - endLine="6" - padding="0" location="[{5,0,null},{6,0,null}]" /> @@ -65,18 +47,12 @@ label="multiline:a" destination="https://example.com/" title="\n1\n2\n3\n" - startLine="16" - endLine="21" - padding="0" location="[{16,0,null},{17,0,null},{18,0,null},{19,0,null},{20,0,null},{21,0,null}]" /> @@ -87,18 +63,12 @@ label="quote:a" destination="https://example.com/" title="example.com" - startLine="30" - endLine="31" - padding="2" location="[{30,2,null},{31,2,null}]" /> @@ -108,18 +78,12 @@ label="quote:c" destination="https://example.com/" title="example.com" - startLine="35" - endLine="36" - padding="4" location="[{35,4,null},{36,4,null}]" /> diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php index 4ef16a1f..7f356eb0 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php @@ -30,10 +30,7 @@ public function getXmlAttributes(Node $node): array { 'label' => $this->escape($node->getLabel()), 'destination' => $this->escape($node->getDestination()), 'title' => $this->escape($node->getTitle()), - 'startLine' => $node->getStartLine() ?? 'null', - 'endLine' => $node->getEndLine() ?? 'null', - 'padding' => $node->getPadding(), - 'location' => $this->location($node->getLocation()), + 'location' => $this->location($node), ]; } } diff --git a/packages/documentator/src/Markdown/XmlRenderer.php b/packages/documentator/src/Markdown/XmlRenderer.php index 35aceb7e..ca1abb19 100644 --- a/packages/documentator/src/Markdown/XmlRenderer.php +++ b/packages/documentator/src/Markdown/XmlRenderer.php @@ -25,10 +25,11 @@ protected function escape(?string $string): string { return preg_replace('/\R/u', '\\n', $string ?? '') ?? $string ?? ''; } - protected function location(?Location $location): string { - $lines = []; + protected function location(Node $node): string { + $lines = []; + $location = Data::get($node, Location::class) ?? []; - foreach ($location ?? [] as $line) { + foreach ($location as $line) { $lines[] = '{'.implode(',', [$line->line, $line->offset, $line->length ?? 'null']).'}'; } From 8708e60bd16376d3e8b7262165264b00441eb436 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:12:58 +0400 Subject: [PATCH 13/20] Inline nodes location detection (tables). --- .../documentator/src/Markdown/Data/Offset.php | 22 ++ .../documentator/src/Markdown/Extension.php | 6 + .../src/Markdown/Nodes/Locator/Listener.php | 170 ++++++++++++++++ .../src/Markdown/Nodes/Locator/Parser.php | 192 +++++++++--------- .../Nodes/Locator/ParserTest~document.md | 13 +- .../Nodes/Locator/ParserTest~expected.xml | 80 +++++++- .../src/Markdown/Nodes/Locator/Utils.php | 109 ++++++++++ 7 files changed, 499 insertions(+), 93 deletions(-) create mode 100644 packages/documentator/src/Markdown/Data/Offset.php create mode 100644 packages/documentator/src/Markdown/Nodes/Locator/Listener.php create mode 100644 packages/documentator/src/Markdown/Nodes/Locator/Utils.php diff --git a/packages/documentator/src/Markdown/Data/Offset.php b/packages/documentator/src/Markdown/Data/Offset.php new file mode 100644 index 00000000..a13f0037 --- /dev/null +++ b/packages/documentator/src/Markdown/Data/Offset.php @@ -0,0 +1,22 @@ + + */ +readonly class Offset implements Value { + public function __construct( + private int $value, + ) { + // empty + } + + #[Override] + public function get(): mixed { + return $this->value; + } +} diff --git a/packages/documentator/src/Markdown/Extension.php b/packages/documentator/src/Markdown/Extension.php index eac784df..edfca121 100644 --- a/packages/documentator/src/Markdown/Extension.php +++ b/packages/documentator/src/Markdown/Extension.php @@ -4,9 +4,11 @@ use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator\Listener; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator\Parser; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\ParserStart as ReferenceParser; use League\CommonMark\Environment\EnvironmentBuilderInterface; +use League\CommonMark\Event\DocumentParsedEvent; use League\CommonMark\Event\DocumentPreParsedEvent; use League\CommonMark\Extension\CommonMark\Parser\Inline\CloseBracketParser; use League\CommonMark\Extension\ExtensionInterface; @@ -39,6 +41,10 @@ static function (DocumentPreParsedEvent $event) use ($referenceParser): void { $referenceParser->setReferenceMap($event->getDocument()->getReferenceMap()); }, + ) + ->addEventListener( + DocumentParsedEvent::class, + new Listener(), ); } } diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Listener.php b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php new file mode 100644 index 00000000..e1a12c29 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php @@ -0,0 +1,170 @@ +getDocument(); + + foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { + if ($node instanceof TableSection) { + $this->fixTableSection($document, $node); + } elseif ($node instanceof TableRow) { + $this->fixTableRow($document, $node); + } else { + // empty + } + } + + // Finalize Locations + foreach ($this->environment?->getInlineParsers() ?? [] as $parser) { + if ($parser instanceof Parser) { + $parser->finalize(); + } + } + } + + #[Override] + public function setEnvironment(EnvironmentInterface $environment): void { + $this->environment = $environment; + } + + private function fixTableSection(Document $document, TableSection $section): void { + // Fixed? + if ($section->getStartLine() !== null && $section->getEndLine() !== null) { + return; + } + + // Fix + $previous = Cast::toNullable(TableSection::class, $section->previous()); + $rows = count($this->toArray($section->children())); + $start = null; + $end = null; + + if ($previous) { + $start = $previous->getEndLine(); + + if ($start !== null) { + $start = $start + 1 + 1; // Each table has a `|----|` line, thus `+1`. + $end = $start + $rows - 1; + } + } else { + $start = Cast::toNullable(Table::class, $section->parent())?->getStartLine(); + + if ($start !== null) { + $start = $start - 1; // Looks like `Table::getStartLine()` is incorrect... + $end = $start + $rows - 1; + } + } + + $section->setStartLine($start); + $section->setEndLine($end); + + Utils::getPadding($section, $start, '|'); + } + + private function fixTableRow(Document $document, TableRow $row): void { + // Fixed? + if (($row->getStartLine() !== null && $row->getEndLine() !== null)) { + return; + } + + // Fix + $line = Cast::toNullable(TableSection::class, $row->parent())?->getStartLine(); + $line = $line !== null + ? $line + Utils::getPosition($row) + : null; + + $row->setStartLine($line); + $row->setEndLine($line); + + if ($line === null) { + return; + } + + // Go to Cells? + $padding = Utils::getPadding($row, $line, '|'); + $text = Utils::getLine($document, $line); + + if ($padding === null || $text === null) { + return; + } + + // Yep + $cells = preg_split('/(?toArray($row->children()); + + if (count($children) !== count($cells)) { + return; + } + + foreach ($children as $cell) { + $cell = Cast::to(TableCell::class, $cell); + $content = $cells[$index]; + $length = mb_strlen($content); + $trimmed = $length - mb_strlen(ltrim($content)); + $unused = $length - $trimmed; + $offset = $offset + $trimmed + 1; + + $cell->setStartLine($line); + $cell->setEndLine($line); + + Data::set($cell, new Padding($padding)); + Data::set($cell, new Offset($offset - $padding)); + + $offset += $unused; + $index += 1; + } + } + + /** + * @template T + * + * @param iterable $iterable + * + * @return array + */ + private function toArray(iterable $iterable): array { + return $iterable instanceof Traversable + ? iterator_to_array($iterable) + : $iterable; + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php index f888e679..6a19e489 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php @@ -3,9 +3,10 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; use LastDragon_ru\LaraASP\Documentator\Markdown\Data; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Offset; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Padding; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Delimiter\DelimiterInterface; @@ -13,8 +14,7 @@ use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\EnvironmentAwareInterface; use League\CommonMark\Environment\EnvironmentInterface; -use League\CommonMark\Node\Block\AbstractBlock; -use League\CommonMark\Node\Block\Document; +use League\CommonMark\Extension\Table\TableCell; use League\CommonMark\Node\Node; use League\CommonMark\Parser\Inline\InlineParserInterface; use League\CommonMark\Parser\Inline\InlineParserMatch; @@ -23,14 +23,15 @@ use League\Config\ConfigurationInterface; use Override; use ReflectionProperty; +use WeakMap; use function array_slice; use function count; use function end; use function implode; use function mb_strlen; -use function mb_strpos; use function mb_substr; +use function mb_substr_count; use function reset; /** @@ -43,10 +44,15 @@ * @see Environment */ class Parser implements InlineParserInterface, EnvironmentAwareInterface, ConfigurationAwareInterface { + /** + * @var WeakMap + */ + private WeakMap $incomplete; + public function __construct( private readonly InlineParserInterface $parser, ) { - // empty + $this->incomplete = new WeakMap(); } #[Override] @@ -62,48 +68,104 @@ public function parse(InlineParserContext $inlineContext): bool { $offset = $cursor->getPosition() - $this->getDelimiterStackLength($inlineContext->getDelimiterStack()) // delimiters length - mb_strlen($cursor->getPreviousText()); // text after delimiter + + // Parse $parsed = $this->parser->parse($inlineContext); - if ($parsed) { - $container = $inlineContext->getContainer(); - $startLine = $container->getStartLine(); - $endLine = $container->getEndLine(); - $child = $container->lastChild(); + if (!$parsed) { + return false; + } - if ($child !== null && $startLine !== null && $endLine !== null) { - $length = $cursor->getPosition() - $offset; - $line = $cursor->getLine(); - $tail = $line; + // Detect Location + $container = $inlineContext->getContainer(); + $startLine = $container->getStartLine(); + $endLine = $container->getEndLine(); + $length = $cursor->getPosition() - $offset; + $child = $container->lastChild(); + $line = $cursor->getLine(); + + if ($child !== null && $startLine !== null && $endLine !== null) { + $start = $line; + + if ($startLine !== $endLine) { + $before = mb_substr($line, 0, $offset); + $beforeLines = Text::getLines($before); + $beforeLinesCount = count($beforeLines) - 1; + $inline = mb_substr($line, $offset, $length); + $inlineLines = Text::getLines($inline); + $inlineLinesCount = count($inlineLines) - 1; + $startLine = $startLine + $beforeLinesCount; + $endLine = $startLine + $inlineLinesCount; + $start = (end($beforeLines) ?: '').(reset($inlineLines) ?: ''); + + if ($beforeLinesCount) { + $offset -= (mb_strlen(implode("\n", array_slice($beforeLines, 0, -1))) + 1); + } if ($startLine !== $endLine) { - $before = mb_substr($line, 0, $offset); - $beforeLines = Text::getLines($before); - $beforeLinesCount = count($beforeLines) - 1; - $inline = mb_substr($line, $offset, $length); - $inlineLines = Text::getLines($inline); - $inlineLinesCount = count($inlineLines) - 1; - $startLine = $startLine + $beforeLinesCount; - $endLine = $startLine + $inlineLinesCount; - $tail = (end($beforeLines) ?: '').(reset($inlineLines) ?: ''); - - if ($beforeLinesCount) { - $offset -= (mb_strlen(implode("\n", array_slice($beforeLines, 0, -1))) + 1); - } - - if ($startLine !== $endLine) { - $length -= mb_strlen($inline); - } + $length -= mb_strlen($inline); } + } - $padding = $this->getBlockPadding($child, $startLine, $tail); + $padding = Utils::getPadding($child, $startLine, $start); - if ($padding !== null) { - Data::set($child, new Location(new Locator($startLine, $endLine, $offset, $length, $padding))); - } + if ($padding !== null) { + Data::set($child, new Location(new Locator($startLine, $endLine, $offset, $length, $padding))); + } + } elseif ($child !== null && $container instanceof TableCell) { + // The properties of the `TableCell` is not known yet (v2.4.2), we + // should wait until parsing is complete. + // + // Also, escaped `|` passed down to inline parsing as an unescaped + // pipe character. It leads to invalid `$offset`/`$length`. + $offset += mb_substr_count(mb_substr($line, 0, $offset), '|'); + $length += mb_substr_count(mb_substr($line, $offset, $length), '|'); + $this->incomplete[$child] = new Coordinate(-1, $offset, $length); + } else { + // empty + } + + // Ok + return true; + } + + public function finalize(): void { + // Complete detection + foreach ($this->incomplete as $node => $coordinate) { + // Container? + $container = Utils::getContainer($node); + + if ($container === null) { + continue; + } + + // Detected? + $startLine = $container->getStartLine(); + $endLine = $container->getEndLine(); + $padding = Data::get($container, Padding::class); + $offset = Data::get($container, Offset::class); + + if ($startLine === null || $endLine === null || $padding === null || $offset === null) { + continue; } + + // Set + Data::set( + $node, + new Location( + new Locator( + $startLine, + $endLine, + $coordinate->offset + $offset, + $coordinate->length, + $padding, + ), + ), + ); } - return $parsed; + // Cleanup + $this->incomplete = new WeakMap(); } #[Override] @@ -122,62 +184,10 @@ public function setConfiguration(ConfigurationInterface $configuration): void { private function getDelimiterStackLength(DelimiterStack $stack): int { $delimiter = (new ReflectionProperty($stack, 'top'))->getValue($stack); - $length = 0; - - if ($delimiter instanceof DelimiterInterface) { - $length += $delimiter->getLength(); - } + $length = $delimiter instanceof DelimiterInterface + ? $delimiter->getLength() + : 0; return $length; } - - private function getBlockPadding(Node $node, int $line, string $tail): ?int { - // Search for Document - $document = null; - $padding = null; - $parent = $node; - $block = null; - - do { - // Document? - if ($parent instanceof Document) { - $document = $parent; - break; - } - - // Cached? - if ($parent instanceof AbstractBlock && $block === null) { - $block = $parent; - $padding = Data::get($block, Padding::class); - - if ($padding !== null) { - break; - } - } - - // Deep - $parent = $parent->parent(); - } while ($parent); - - if ($document === null) { - return $padding; - } - - // Detect block padding - // (we are expecting that all lines inside block have the same padding) - $lines = Data::get($document, Lines::class) ?? []; - $padding = mb_strpos($lines[$line] ?? '', $tail); - - if ($padding === false) { - return null; - } - - // Cache - if ($block) { - Data::set($block, new Padding($padding)); - } - - // Return - return $padding; - } } diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md index b0fd19b7..bbd71fd7 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md @@ -15,7 +15,7 @@ text text _[link](https://example.com/)_ text. * List list [link](https://example.com/). * List list [link](https://example.com/). - * List list [link](https://example.com/). + * List list [link](https://example.com/ "\\|"). # Quotes @@ -25,3 +25,14 @@ text text _[link](https://example.com/)_ text. > quote quote [link](https://example.com/) quote. > > Quote quote [link](https://example.com/). + +# Tables + +| Header | Header ([link](https://example.com/)) | +|-------------------------|---------------------------------------------------------------------------| +| Cell [link][link] cell. | Cell | +| Cell | Cell cell [link](https://example.com/) cell [link](https://example.com/). | + +> | Header | Header | +> |--------------------------------------------------|--------| +> | Cell `\|` \\| [link](https://example.com/ "\\|") | Cell | diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml index 4372c8a2..7926173e 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml @@ -72,7 +72,7 @@ - + link . @@ -113,4 +113,82 @@ + + Tables + + + + + + Header + + + Header ( + + link + + ) + + + + + + + + + link + + + + + Cell + + + + + Cell + + + + + link + + + + link + + . + + + +
+ + + + + + Header + + + Header + + + + + + + + | + | + + link + + + + Cell + + + +
+
diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Utils.php b/packages/documentator/src/Markdown/Nodes/Locator/Utils.php new file mode 100644 index 00000000..8e565633 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Locator/Utils.php @@ -0,0 +1,109 @@ +previous()) { + $position++; + } + + return $position; + } + + /** + * Detect block padding. We are expecting that all lines inside the block + * have the same padding. + */ + public static function getPadding(Node $node, ?int $line, ?string $start): ?int { + // Container? + $container = self::getContainer($node); + + if ($container === null) { + return null; + } + + // Known? + $padding = Data::get($container, Padding::class); + + if ($padding !== null) { + return $padding; + } + + // Possible? + if ($line === null || $start === null) { + return null; + } + + // Document? + $document = self::getDocument($container); + + if ($document === null) { + return null; + } + + // Detect + $padding = mb_strpos(self::getLine($document, $line) ?? '', $start); + + if ($padding === false) { + return null; + } + + // Cache + Data::set($container, new Padding($padding)); + + // Return + return $padding; + } + + public static function getLine(Document $document, int $line): ?string { + $lines = Data::get($document, Lines::class) ?? []; + $line = $lines[$line] ?? null; + + return $line; + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return ?T + */ + private static function getParent(Node $node, string $class): ?object { + $parent = null; + + do { + if ($node instanceof $class) { + $parent = $node; + break; + } + + $node = $node->parent(); + } while ($node); + + return $parent; + } +} From 807b06ad87c89156c097e0bcc9652ca790d07438 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Sat, 3 Aug 2024 09:59:43 +0400 Subject: [PATCH 14/20] `Editor` helper to make `Location` based modifications. --- .../src/Markdown/Data/Location.php | 4 +- .../documentator/src/Markdown/Document.php | 52 +---- packages/documentator/src/Markdown/Editor.php | 177 ++++++++++++++++++ .../documentator/src/Markdown/EditorTest.php | 118 ++++++++++++ .../src/Markdown/Location/Location.php | 2 +- .../src/Markdown/Location/Locator.php | 5 + 6 files changed, 310 insertions(+), 48 deletions(-) create mode 100644 packages/documentator/src/Markdown/Editor.php create mode 100644 packages/documentator/src/Markdown/EditorTest.php diff --git a/packages/documentator/src/Markdown/Data/Location.php b/packages/documentator/src/Markdown/Data/Location.php index 788a1994..a8e25f0c 100644 --- a/packages/documentator/src/Markdown/Data/Location.php +++ b/packages/documentator/src/Markdown/Data/Location.php @@ -9,9 +9,9 @@ * @internal * @implements Value */ -class Location implements Value { +readonly class Location implements Value { public function __construct( - private readonly LocationContract $location, + private LocationContract $location, ) { // empty } diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index 20b62d28..e17dc905 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -6,9 +6,7 @@ use LastDragon_ru\LaraASP\Core\Utils\Path; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location as LocationData; -use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; -use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock; use League\CommonMark\Extension\CommonMark\Node\Inline\AbstractWebResource; @@ -21,13 +19,11 @@ use Override; use Stringable; -use function array_filter; use function array_slice; use function count; use function filter_var; use function implode; use function ltrim; -use function mb_substr; use function preg_match; use function str_contains; use function str_ends_with; @@ -113,6 +109,7 @@ public function setPath(?string $path): static { // Update $resources = $this->getRelativeResources(); + $changes = []; $lines = $this->getLines(); $path = Path::normalize($path); $getUrl = static function (string $url): string { @@ -138,42 +135,6 @@ public function setPath(?string $path): static { return $title; }; - $replace = static function (array &$lines, Location $location, string $text): void { - // Replace lines - $last = null; - $line = null; - $text = Text::getLines($text); - $index = 0; - $number = null; - - foreach ($location as $coordinate) { - $last = $coordinate; - $number = $coordinate->line; - $line = $lines[$number] ?? ''; - $prefix = mb_substr($line, 0, $coordinate->offset); - $suffix = $coordinate->length - ? mb_substr($line, $coordinate->offset + $coordinate->length) - : ''; - - if (isset($text[$index])) { - $lines[$number] = $prefix.$text[$index].$suffix; - } else { - $lines[$number] = null; - } - - $index++; - } - - // Parser uses the empty line right after the block as an End Line. - // We should preserve it. - if ($last !== null) { - $content = mb_substr($line, $last->offset); - - if ($content === '') { - $lines[$number] = mb_substr($line, 0, $last->offset); - } - } - }; foreach ($resources as $resource) { if ($resource instanceof Reference) { @@ -185,16 +146,17 @@ public function setPath(?string $path): static { $text = trim("[{$label}]: {$target} {$title}"); if ($location) { - $replace($lines, $location, $text); + $changes[] = [$location, $text]; } } } // Update - if ($resources) { - $this->setContent( - implode("\n", array_filter($lines, static fn ($line) => $line !== null)), - ); + if ($changes) { + $lines = (new Editor())->modify($lines, $changes); + $content = implode("\n", $lines); + + $this->setContent($content); } $this->path = $path; diff --git a/packages/documentator/src/Markdown/Editor.php b/packages/documentator/src/Markdown/Editor.php new file mode 100644 index 00000000..6bf14251 --- /dev/null +++ b/packages/documentator/src/Markdown/Editor.php @@ -0,0 +1,177 @@ + $lines + * @param array $changes + * + * @return array + */ + public function modify(array $lines, array $changes): array { + // Modify + $changes = $this->removeOverlaps($changes); + $changes = $this->expand($changes); + $paddings = []; + + foreach ($changes as $change) { + [$coordinate, $padding, $text] = $change; + $line = $lines[$coordinate->line] ?? ''; + $prefix = mb_substr($line, 0, $coordinate->offset); + $suffix = $coordinate->length + ? mb_substr($line, $coordinate->offset + $coordinate->length) + : ''; + $lines[$coordinate->line] = $prefix.$text.$suffix; + $paddings[$coordinate->line] = $padding; + + if ($text === null && !$suffix) { + $lines[$coordinate->line] = trim($prefix); + } + } + + // Markdown Parser uses the empty line right after the block as an + // End Line. We are attempting to preserve them, and also merge + // multiple empty lines into one. + $previous = ''; + + foreach ($lines as $line => $text) { + $content = mb_substr($text, $paddings[$line] ?? 0); + $padding = mb_substr($text, 0, $paddings[$line] ?? 0); + + if ($content === '') { + if ($previous !== '') { + $lines[$line] = $padding; + } else { + unset($lines[$line]); + } + } + + $previous = $content; + } + + // Remove last line if empty + $last = array_key_last($lines); + $content = mb_substr($lines[$last] ?? '', $paddings[$last] ?? 0); + + if ($content === '') { + unset($lines[$last]); + } + + // Return + return $lines; + } + + /** + * @param array $changes + * + * @return list + */ + protected function expand(array $changes): array { + $expanded = []; + $sort = static function (Coordinate $a, Coordinate $b): int { + return $a->line <=> $b->line ?: $a->offset <=> $b->offset; + }; + + foreach (array_reverse($changes, true) as $change) { + [$location, $text] = $change; + $coordinates = iterator_to_array($location); + $text = $text ? Text::getLines($text) : []; + $line = 0; + + usort($coordinates, $sort); + + foreach ($coordinates as $coordinate) { + $expanded[] = [$coordinate, $location->getPadding(), $text[$line++] ?? null]; + } + + // If `$text` contains more lines than `$coordinates` that means + // that these lines should be added after the last `$coordinate`. + // + // Not supported yet 🤷‍♂️ + } + + usort($expanded, static fn ($a, $b) => -$sort($a[0], $b[0])); + + return $expanded; + } + + /** + * @param array $changes + * + * @return array + */ + protected function removeOverlaps(array $changes): array { + $used = []; + + foreach (array_reverse($changes, true) as $key => $change) { + [$location] = $change; + $coordinates = iterator_to_array($location); + + usort($coordinates, static function (Coordinate $a, Coordinate $b): int { + return $b->line <=> $a->line; + }); + + foreach ($coordinates as $coordinate) { + if ($this->isOverlapped($used, $coordinate)) { + $coordinates = null; + break; + } + } + + if ($coordinates) { + $used = array_merge($used, $coordinates); + } else { + unset($changes[$key]); + } + } + + // Return + return $changes; + } + + /** + * @param array $coordinates + */ + private function isOverlapped(array $coordinates, Coordinate $coordinate): bool { + $overlapped = false; + + for ($i = count($coordinates) - 1; $i >= 0; $i--) { + if ($coordinate->line === $coordinates[$i]->line) { + $aStart = $coordinates[$i]->offset; + $aEnd = $aStart + ($coordinates[$i]->length ?? PHP_INT_MAX); + $bStart = $coordinate->offset; + $bEnd = $bStart + ($coordinate->length ?? PHP_INT_MAX); + $overlapped = !($bEnd < $aStart || $bStart > $aEnd); + } + + if ($overlapped || $coordinate->line < $coordinates[$i]->line) { + break; + } + } + + return $overlapped; + } +} diff --git a/packages/documentator/src/Markdown/EditorTest.php b/packages/documentator/src/Markdown/EditorTest.php new file mode 100644 index 00000000..4ea4ed14 --- /dev/null +++ b/packages/documentator/src/Markdown/EditorTest.php @@ -0,0 +1,118 @@ + 'a b c d', + 2 => 'e f g h', + 3 => 'i j k l', + 4 => 'm n o p', + 5 => '', + 6 => 'q r s t', + 7 => 'u v w x', + 8 => '', + 9 => 'y z', + 10 => '', + 11 => '> a b c d', + 12 => '> e f g h', + 13 => '>', + 14 => '> i j k l', + 15 => '>', + ]; + $changes = [ + [new Locator(1, 1, 2, 3), '123'], + [new Locator(2, 4, 4, 4), '123'], + [new Locator(6, 8, 4, 4), "123\n345"], + [new Locator(11, 12, 4, 3, 2), "123\n345"], + [new Locator(14, 15, 4, 3, 2), '123'], + ]; + $expected = [ + 1 => 'a 123 d', + 2 => 'e f 123', + 3 => '', + 4 => 'o p', + 5 => '', + 6 => 'q r 123', + 7 => '345', + 8 => '', + 9 => 'y z', + 10 => '', + 11 => '> a b 123', + 12 => '> 345 g h', + 13 => '>', + 14 => '> i j 123', + ]; + + self::assertSame($expected, $editor->modify($lines, $changes)); + } + + public function testRemoveOverlaps(): void { + $editor = new class() extends Editor { + /** + * @inheritDoc + */ + #[Override] + public function removeOverlaps(array $changes): array { + return parent::removeOverlaps($changes); + } + }; + $changes = [ + 0 => [new Locator(10, 10, 15, 10), 'a'], + 1 => [new Locator(10, 10, 10, null), 'b'], + 2 => [new Locator(12, 15, 5, 10), 'c'], + 3 => [new Locator(14, 15, 5, 10), 'd'], + 4 => [new Locator(17, 17, 5, 10), 'e'], + 5 => [new Locator(17, 17, 11, 10), 'f'], + 6 => [new Locator(18, 18, 5, 10), 'g'], + ]; + $expected = [ + 1 => [new Locator(10, 10, 10, null), 'b'], + 3 => [new Locator(14, 15, 5, 10), 'd'], + 5 => [new Locator(17, 17, 11, 10), 'f'], + 6 => [new Locator(18, 18, 5, 10), 'g'], + ]; + + self::assertEquals($expected, $editor->removeOverlaps($changes)); + } + + public function testExpand(): void { + $editor = new class() extends Editor { + /** + * @inheritDoc + */ + #[Override] + public function expand(array $changes): array { + return parent::expand($changes); + } + }; + $changes = [ + [new Locator(1, 1, 5, 10), 'text'], + [new Locator(2, 3, 5, null), 'text'], + [new Locator(4, 5, 5, 5, 1), "text a\ntext b"], + [new Locator(6, 6, 5, 10, 2), "text a\ntext b"], + ]; + $expected = [ + [new Coordinate(6, 7, 10), 2, 'text a'], + [new Coordinate(5, 1, 5), 1, 'text b'], + [new Coordinate(4, 6, null), 1, 'text a'], + [new Coordinate(3, 0, null), 0, null], + [new Coordinate(2, 5, null), 0, 'text'], + [new Coordinate(1, 5, 10), 0, 'text'], + ]; + + self::assertEquals($expected, $editor->expand($changes)); + } +} diff --git a/packages/documentator/src/Markdown/Location/Location.php b/packages/documentator/src/Markdown/Location/Location.php index 95b5c6dd..8a6ec02d 100644 --- a/packages/documentator/src/Markdown/Location/Location.php +++ b/packages/documentator/src/Markdown/Location/Location.php @@ -8,5 +8,5 @@ * @extends IteratorAggregate */ interface Location extends IteratorAggregate { - // empty + public function getPadding(): int; } diff --git a/packages/documentator/src/Markdown/Location/Locator.php b/packages/documentator/src/Markdown/Location/Locator.php index d31e77f6..60323e1d 100644 --- a/packages/documentator/src/Markdown/Location/Locator.php +++ b/packages/documentator/src/Markdown/Location/Locator.php @@ -19,6 +19,11 @@ public function __construct( // empty } + #[Override] + public function getPadding(): int { + return $this->padding; + } + /** * @return Traversable */ From 0f10379c8ad0aaf7398049a3256f019070a251d8 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Sat, 3 Aug 2024 10:47:34 +0400 Subject: [PATCH 15/20] Reorganization. --- .../src/Markdown/{ => Data}/Data.php | 3 +- .../documentator/src/Markdown/Document.php | 59 ++++++++----------- .../src/Markdown/DocumentTest.php | 10 ++++ .../documentator/src/Markdown/Extension.php | 2 + .../src/Markdown/ExtensionTest.php | 1 + .../src/Markdown/Nodes/Locator/Listener.php | 3 +- .../src/Markdown/Nodes/Locator/Parser.php | 3 +- .../Nodes/Reference/ParserContinue.php | 2 +- .../Markdown/{Nodes/Locator => }/Utils.php | 47 ++++++++++++++- .../documentator/src/Markdown/XmlRenderer.php | 1 + 10 files changed, 88 insertions(+), 43 deletions(-) rename packages/documentator/src/Markdown/{ => Data}/Data.php (89%) rename packages/documentator/src/Markdown/{Nodes/Locator => }/Utils.php (62%) diff --git a/packages/documentator/src/Markdown/Data.php b/packages/documentator/src/Markdown/Data/Data.php similarity index 89% rename from packages/documentator/src/Markdown/Data.php rename to packages/documentator/src/Markdown/Data/Data.php index 90b5dd51..1f617c7d 100644 --- a/packages/documentator/src/Markdown/Data.php +++ b/packages/documentator/src/Markdown/Data/Data.php @@ -1,8 +1,7 @@ getRelativeResources(); $changes = []; + $editor = new Editor(); $lines = $this->getLines(); $path = Path::normalize($path); - $getUrl = static function (string $url): string { - return preg_match('/\s/u', $url) - ? '<'.strtr($url, ['<' => '\\\\<', '>' => '\\\\>']).'>' - : $url; - }; - $getText = static function (string $text): string { - return strtr($text, ['[' => '\\\\[', ']' => '\\\\]']); - }; - $getTitle = static function (string $title): string { - if ($title === '') { - // no action - } elseif (!str_contains($title, '(') && !str_contains($title, ')')) { - $title = "({$title})"; - } elseif (!str_contains($title, '"')) { - $title = "\"{$title}\""; - } elseif (!str_contains($title, "'")) { - $title = "'{$title}'"; - } else { - $title = '('.strtr($title, ['(' => '\\\\(', ')' => '\\\\)']).')'; + + foreach ($resources as $resource) { + // Location? + $location = Data::get($resource, LocationData::class); + + if (!$location) { + continue; } - return $title; - }; + // Update + $text = null; - foreach ($resources as $resource) { if ($resource instanceof Reference) { - $location = Data::get($resource, LocationData::class); - $origin = Path::getPath($this->path, $resource->getDestination()); - $target = $getUrl(Path::getRelativePath($path, $origin)); - $label = $getText($resource->getLabel()); - $title = $getTitle($resource->getTitle()); - $text = trim("[{$label}]: {$target} {$title}"); - - if ($location) { - $changes[] = [$location, $text]; - } + $target = Path::getPath($this->path, $resource->getDestination()); + $target = Path::getRelativePath($path, $target); + $label = $resource->getLabel(); + $title = $resource->getTitle(); + $text = Utils::getReferenceDefinition($label, $target, $title); + } else { + // skipped + } + + if ($text !== null) { + $changes[] = [$location, $text]; } } // Update if ($changes) { - $lines = (new Editor())->modify($lines, $changes); + $lines = $editor->modify($lines, $changes); $content = implode("\n", $lines); $this->setContent($content); diff --git a/packages/documentator/src/Markdown/DocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php index 022590a3..efac8fd3 100644 --- a/packages/documentator/src/Markdown/DocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -264,6 +264,11 @@ public static function dataProviderSetPath(): array { # Special + ## Target escaping + + [title]: ../from/file/%20/a + [title]: ../from/file/%20/a + ## Title escaping ### can be avoided @@ -310,6 +315,11 @@ public static function dataProviderSetPath(): array { # Special + ## Target escaping + + [title]: ./file/%20/a + [title]: <./file/ /a> + ## Title escaping ### can be avoided diff --git a/packages/documentator/src/Markdown/Extension.php b/packages/documentator/src/Markdown/Extension.php index edfca121..802d9689 100644 --- a/packages/documentator/src/Markdown/Extension.php +++ b/packages/documentator/src/Markdown/Extension.php @@ -2,6 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator\Listener; @@ -20,6 +21,7 @@ * We use it for: * * find Reference nodes and their location inside the document * (by default, they are not added to the AST) + * * determine location of the Links/Images * * @see https://github.com/thephpleague/commonmark/discussions/1036 * @see Coordinate diff --git a/packages/documentator/src/Markdown/ExtensionTest.php b/packages/documentator/src/Markdown/ExtensionTest.php index 67089001..09c0fa02 100644 --- a/packages/documentator/src/Markdown/ExtensionTest.php +++ b/packages/documentator/src/Markdown/ExtensionTest.php @@ -2,6 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Listener.php b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php index e1a12c29..887d7388 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Listener.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php @@ -3,9 +3,10 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; use LastDragon_ru\LaraASP\Core\Utils\Cast; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Offset; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Padding; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; use League\CommonMark\Environment\EnvironmentAwareInterface; use League\CommonMark\Environment\EnvironmentInterface; use League\CommonMark\Event\DocumentParsedEvent; diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php index 6a19e489..6ae1c36c 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php @@ -2,12 +2,13 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Offset; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Padding; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Delimiter\DelimiterInterface; use League\CommonMark\Delimiter\DelimiterStack; diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php index 35c82ae9..82f3a555 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php @@ -2,7 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Padding; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Utils.php b/packages/documentator/src/Markdown/Utils.php similarity index 62% rename from packages/documentator/src/Markdown/Nodes/Locator/Utils.php rename to packages/documentator/src/Markdown/Utils.php index 8e565633..ede80b2d 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Utils.php +++ b/packages/documentator/src/Markdown/Utils.php @@ -1,8 +1,8 @@ '\\\\[', ']' => '\\\\]']); + } + + private static function getLinkTarget(string $target): string { + return preg_match('/\s/u', $target) + ? '<'.strtr($target, ['<' => '\\\\<', '>' => '\\\\>']).'>' + : $target; + } + + private static function getLinkTitle(string $title): string { + if ($title === '') { + // no action + } elseif ((!str_contains($title, '(') && !str_contains($title, ')'))) { + $title = "({$title})"; + } elseif (!str_contains($title, '"')) { + $title = "\"{$title}\""; + } elseif (!str_contains($title, "'")) { + $title = "'{$title}'"; + } else { + $title = '('.strtr($title, ['(' => '\\\\(', ')' => '\\\\)']).')'; + } + + return $title; + } + /** * @template T of object * diff --git a/packages/documentator/src/Markdown/XmlRenderer.php b/packages/documentator/src/Markdown/XmlRenderer.php index ca1abb19..85b553dd 100644 --- a/packages/documentator/src/Markdown/XmlRenderer.php +++ b/packages/documentator/src/Markdown/XmlRenderer.php @@ -2,6 +2,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location; use League\CommonMark\Node\Node; use League\CommonMark\Renderer\ChildNodeRendererInterface; From b0604cda36d9c0f9f19e9183b711eb02c4d9e19a Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Sat, 3 Aug 2024 17:45:02 +0400 Subject: [PATCH 16/20] Original links form preservation. --- .../documentator/src/Markdown/Document.php | 20 ++++--- .../src/Markdown/DocumentTest.php | 20 +++---- packages/documentator/src/Markdown/Editor.php | 25 +++++++++ .../documentator/src/Markdown/EditorTest.php | 25 +++++++++ packages/documentator/src/Markdown/Utils.php | 54 ++++++++++++------- 5 files changed, 108 insertions(+), 36 deletions(-) diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index c0982022..f527e2c9 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -25,6 +25,10 @@ use function filter_var; use function implode; use function ltrim; +use function mb_substr; +use function preg_match; +use function preg_quote; +use function rawurldecode; use function str_ends_with; use function str_starts_with; use function trim; @@ -121,14 +125,18 @@ public function setPath(?string $path): static { } // Update - $text = null; + $text = null; + $origin = trim((string) $editor->getText($lines, $location)); if ($resource instanceof Reference) { - $target = Path::getPath($this->path, $resource->getDestination()); - $target = Path::getRelativePath($path, $target); - $label = $resource->getLabel(); - $title = $resource->getTitle(); - $text = Utils::getReferenceDefinition($label, $target, $title); + $titleWrapper = mb_substr($origin, -1, 1); + $wrapTarget = (bool) preg_match('/^\['.preg_quote($resource->getLabel(), '/').']:\s+getDestination()); + $target = Path::getPath($this->path, $target); + $target = Path::getRelativePath($path, $target); + $label = $resource->getLabel(); + $title = $resource->getTitle(); + $text = Utils::getLink('[%s]: %s %s', $label, $target, $title, $wrapTarget, $titleWrapper); } else { // skipped } diff --git a/packages/documentator/src/Markdown/DocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php index efac8fd3..3ca3bff2 100644 --- a/packages/documentator/src/Markdown/DocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -243,8 +243,8 @@ public static function dataProviderSetPath(): array { [tel]: tel:+70000000000 "title" [link]: ../from/file/a - [link]: ../from/file/b (title) - [title]: ../from/file/a (title) + [link]: ../from/file/b ' ' + [title]: <../from/file/a> (title) [unused]: ../path/to/file [mailto]: mailto:mail@example.com [absolute]: /path/to/file 'title' @@ -253,10 +253,10 @@ public static function dataProviderSetPath(): array { [a]: ../from/file/a [a]: ../from/file/b - [b]: ../from/file/b ( + [b]: ../from/file/b " abc 123 - ) + " [c]: ../from/file/c ( title @@ -266,19 +266,19 @@ public static function dataProviderSetPath(): array { ## Target escaping - [title]: ../from/file/%20/a - [title]: ../from/file/%20/a + [title]: ../from/%3Cfile%3E/%20/a + [title]: <../from/file/ /a> ## Title escaping ### can be avoided [title]: ../file/a "title with ( ) and with ' '" - [title]: ../file/a "title with ( ) and with ' '" + [title]: ../file/a (title with \( \) and with ' ') ### cannot - [title]: ../file/a (title with \\( \\) and with ' ' and with " ") + [title]: ../file/a "title with ( ) and with ' ' and with \" \"" ## Inside Quote @@ -292,7 +292,7 @@ public static function dataProviderSetPath(): array { [tel]: tel:+70000000000 "title" [link]: ./file/a - [link]: file/b 'title' + [link]: file/b ' <title> ' [title]: <./file/a> (title) [unused]: ../path/to/file [mailto]: mailto:mail@example.com @@ -317,7 +317,7 @@ public static function dataProviderSetPath(): array { ## Target escaping - [title]: ./file/%20/a + [title]: ./%3Cfile%3E/%20/a [title]: <./file/ /a> ## Title escaping diff --git a/packages/documentator/src/Markdown/Editor.php b/packages/documentator/src/Markdown/Editor.php index 6bf14251..3efad7b8 100644 --- a/packages/documentator/src/Markdown/Editor.php +++ b/packages/documentator/src/Markdown/Editor.php @@ -10,6 +10,7 @@ use function array_merge; use function array_reverse; use function count; +use function implode; use function iterator_to_array; use function mb_substr; use function trim; @@ -25,6 +26,30 @@ public function __construct() { // empty } + /** + * @param array<int, string> $lines + */ + public function getText(array $lines, Location $location): ?string { + // Select + $selected = null; + + foreach ($location as $coordinate) { + if (isset($lines[$coordinate->line])) { + $selected[] = mb_substr($lines[$coordinate->line], $coordinate->offset, $coordinate->length); + } else { + $selected = null; + break; + } + } + + if ($selected === null) { + return null; + } + + // Return + return implode("\n", $selected); + } + /** * @param array<int, string> $lines * @param array<array-key, array{Location, ?string}> $changes diff --git a/packages/documentator/src/Markdown/EditorTest.php b/packages/documentator/src/Markdown/EditorTest.php index 4ea4ed14..8cbaf72b 100644 --- a/packages/documentator/src/Markdown/EditorTest.php +++ b/packages/documentator/src/Markdown/EditorTest.php @@ -115,4 +115,29 @@ public function expand(array $changes): array { self::assertEquals($expected, $editor->expand($changes)); } + + public function testGetText(): void { + $editor = new Editor(); + $lines = [ + 0 => 'a b c d', + 1 => 'e f g h', + 2 => 'i j k l', + 3 => 'm n o p', + 4 => '', + 5 => 'q r s t', + 6 => 'u v w x', + ]; + + self::assertNull($editor->getText($lines, new Locator(25, 25, 0))); + self::assertEquals('f g', $editor->getText($lines, new Locator(1, 1, 2, 3))); + self::assertEquals( + <<<'TEXT' + k l + m n o p + + q r s + TEXT, + $editor->getText($lines, new Locator(2, 5, 4, 5)), + ); + } } diff --git a/packages/documentator/src/Markdown/Utils.php b/packages/documentator/src/Markdown/Utils.php index ede80b2d..b293aaaf 100644 --- a/packages/documentator/src/Markdown/Utils.php +++ b/packages/documentator/src/Markdown/Utils.php @@ -8,9 +8,11 @@ use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Node\Block\Document; use League\CommonMark\Node\Node; +use League\CommonMark\Util\UrlEncoder; use function mb_strpos; use function preg_match; +use function sprintf; use function str_contains; use function strtr; use function trim; @@ -89,42 +91,54 @@ public static function getLine(Document $document, int $line): ?string { return $line; } - public static function getReferenceDefinition( + public static function getLink( + string $format, string $label, string $target, string $title, + ?bool $wrapTarget, + ?string $titleWrapper, ): string { $label = self::getLinkLabel($label); - $title = self::getLinkTitle($title); - $target = self::getLinkTarget($target); - $text = trim("[{$label}]: {$target} {$title}"); + $title = self::getLinkTitle($title, $titleWrapper); + $target = self::getLinkTarget($target, $wrapTarget); + $link = trim(sprintf($format, $label, $target, $title)); - return $text; + return $link; } private static function getLinkLabel(string $label): string { return strtr($label, ['[' => '\\\\[', ']' => '\\\\]']); } - private static function getLinkTarget(string $target): string { - return preg_match('/\s/u', $target) - ? '<'.strtr($target, ['<' => '\\\\<', '>' => '\\\\>']).'>' - : $target; + private static function getLinkTarget(string $target, ?bool $wrap): string { + return ($wrap ?? preg_match('/\s/u', $target)) + ? '<'.strtr($target, ['<' => '\\<', '>' => '\\>']).'>' + : UrlEncoder::unescapeAndEncode($target); } - private static function getLinkTitle(string $title): string { - if ($title === '') { - // no action - } elseif ((!str_contains($title, '(') && !str_contains($title, ')'))) { - $title = "({$title})"; - } elseif (!str_contains($title, '"')) { - $title = "\"{$title}\""; - } elseif (!str_contains($title, "'")) { - $title = "'{$title}'"; - } else { - $title = '('.strtr($title, ['(' => '\\\\(', ')' => '\\\\)']).')'; + private static function getLinkTitle(string $title, ?string $wrapper = null): string { + if (!$title) { + return ''; } + $wrappers = [ + ')' => ['(' => '\\(', ')' => '\\)'], + '"' => ['"' => '\\"'], + "'" => ["'" => "\\'"], + ]; + $wrapper = match (true) { + isset($wrappers[$wrapper]) => $wrapper, + !str_contains($title, '"') => '"', + !str_contains($title, "'") => "'", + default => ')', + }; + $title = match ($wrapper) { + '"' => '"'.strtr($title, $wrappers['"']).'"', + "'" => "'".strtr($title, $wrappers['"'])."'", + default => '('.strtr($title, $wrappers[')']).')', + }; + return $title; } From 72f3e389f18cd6e0a296ded0e02313919b2f58b1 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Sun, 4 Aug 2024 11:48:15 +0400 Subject: [PATCH 17/20] Links and Images support. --- .../documentator/src/Markdown/Document.php | 38 ++++- .../src/Markdown/DocumentTest.php | 153 ++++++++++++++++-- .../src/Markdown/Nodes/Locator/Parser.php | 2 +- .../src/Markdown/Nodes/Locator/ParserTest.php | 2 + .../Nodes/Locator/ParserTest~document.md | 17 ++ .../Nodes/Locator/ParserTest~expected.xml | 76 ++++++++- .../src/Markdown/Nodes/Locator/Renderer.php | 7 +- packages/documentator/src/Markdown/Utils.php | 20 +++ 8 files changed, 294 insertions(+), 21 deletions(-) diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index f527e2c9..36786aea 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -11,10 +11,14 @@ use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock; use League\CommonMark\Extension\CommonMark\Node\Inline\AbstractWebResource; +use League\CommonMark\Extension\CommonMark\Node\Inline\Image; +use League\CommonMark\Extension\CommonMark\Node\Inline\Link; +use League\CommonMark\Extension\Table\TableCell; use League\CommonMark\GithubFlavoredMarkdownConverter; use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Node\Block\Document as DocumentNode; use League\CommonMark\Node\Block\Paragraph; +use League\CommonMark\Node\Inline\Text; use League\CommonMark\Node\Node; use League\CommonMark\Parser\MarkdownParser; use Override; @@ -29,7 +33,9 @@ use function preg_match; use function preg_quote; use function rawurldecode; +use function rtrim; use function str_ends_with; +use function str_replace; use function str_starts_with; use function trim; @@ -128,15 +134,37 @@ public function setPath(?string $path): static { $text = null; $origin = trim((string) $editor->getText($lines, $location)); - if ($resource instanceof Reference) { - $titleWrapper = mb_substr($origin, -1, 1); - $wrapTarget = (bool) preg_match('/^\['.preg_quote($resource->getLabel(), '/').']:\s+</u', $origin); - $target = rawurldecode($resource->getDestination()); + if ($resource instanceof Link || $resource instanceof Image) { + $title = $resource->getTitle(); + $titleWrapper = mb_substr(rtrim(mb_substr($origin, 0, -1)), -1, 1); + $label = (string) Utils::getChild($resource, Text::class)?->getLiteral(); + $target = rawurldecode($resource->getUrl()); $target = Path::getPath($this->path, $target); $target = Path::getRelativePath($path, $target); + $targetWrap = (bool) preg_match('/^!?\['.preg_quote($label, '/').']\(\s*</u', $origin); + + if (Utils::getContainer($resource) instanceof TableCell) { + $title = $title ? str_replace('|', '\\|', $title) : $title; + $label = str_replace('|', '\\|', $label); + $target = str_replace('|', '\\|', $target); + } + + $text = $title + ? Utils::getLink('[%s](%s %s)', $label, $target, $title, $targetWrap, $titleWrapper) + : Utils::getLink('[%s](%s)', $label, $target, '', $targetWrap, $titleWrapper); + + if ($resource instanceof Image) { + $text = "!{$text}"; + } + } elseif ($resource instanceof Reference) { $label = $resource->getLabel(); $title = $resource->getTitle(); - $text = Utils::getLink('[%s]: %s %s', $label, $target, $title, $wrapTarget, $titleWrapper); + $titleWrapper = mb_substr($origin, -1, 1); + $target = rawurldecode($resource->getDestination()); + $target = Path::getPath($this->path, $target); + $target = Path::getRelativePath($path, $target); + $targetWrap = (bool) preg_match('/^\['.preg_quote($resource->getLabel(), '/').']:\s+</u', $origin); + $text = Utils::getLink('[%s]: %s %s', $label, $target, $title, $targetWrap, $titleWrapper); } else { // skipped } diff --git a/packages/documentator/src/Markdown/DocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php index 3ca3bff2..5a2aa041 100644 --- a/packages/documentator/src/Markdown/DocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -236,7 +236,6 @@ public static function dataProviderSetPath(): array { MARKDOWN, '/path', ], - // References 'references' => [ <<<'MARKDOWN' # General @@ -271,13 +270,8 @@ public static function dataProviderSetPath(): array { ## Title escaping - ### can be avoided - [title]: ../file/a "title with ( ) and with ' '" [title]: ../file/a (title with \( \) and with ' ') - - ### cannot - [title]: ../file/a "title with ( ) and with ' ' and with \" \"" ## Inside Quote @@ -322,13 +316,8 @@ public static function dataProviderSetPath(): array { ## Title escaping - ### can be avoided - [title]: ../file/a "title with ( ) and with ' '" [title]: ../file/a (title with \( \) and with ' ') - - ### cannot - [title]: ../file/a "title with ( ) and with ' ' and with \" \"" ## Inside Quote @@ -341,6 +330,148 @@ public static function dataProviderSetPath(): array { MARKDOWN, '/path/to', ], + 'links' => [ + <<<'MARKDOWN' + # General + + Text text [tel](tel:+70000000000 "title") text [link](../from/file/a) + text [link](../from/file/b ' <title> ') text [title](<../from/file/a> (title)) + [mailto](mailto:mail@example.com) text [absolute](/path/to/file 'title') + text [external](https://example.com/). + + # Special + + ## Target escaping + + Text [title](../from/%3Cfile%3E/%20/a) text [title](<../from/file/ /a>). + + ## Title escaping + + Text [title](../file/a "title with ( ) and with ' '") text + text [title](../file/a (title with \( \) and with ' ')) text + text [title](../file/a "title with ( ) and with ' ' and with \" \""). + + ## Inside Quote + + > Text [quote](../file/a) text [quote](https://example.com/) + > text text [quote](../from/file/b (title)). + + ## Inside Table + + | Header | Header ([table](../from/file/b)) | + |-------------------------|-----------------------------------------------------------------| + | Cell [link][link] cell. | Cell `\|` \\| [table](<../from/file\|a> "\|") | + | Cell | Cell cell [table](https://example.com/) cell [table](../from/file/a). | + MARKDOWN, + '/path/from', + <<<'MARKDOWN' + # General + + Text text [tel](tel:+70000000000 "title") text [link](./file/a) + text [link](file/b ' <title> ') text [title](<./file/a> (title)) + [mailto](mailto:mail@example.com) text [absolute](/path/to/file 'title') + text [external](https://example.com/). + + # Special + + ## Target escaping + + Text [title](./%3Cfile%3E/%20/a) text [title](<./file/ /a>). + + ## Title escaping + + Text [title]( ../file/a "title with ( ) and with ' '" ) text + text [title]( ../file/a (title with \( \) and with ' ')) text + text [title](../file/a "title with ( ) and with ' ' and with \" \""). + + ## Inside Quote + + > Text [quote](../file/a) text [quote](https://example.com/) + > text text [quote](file/b (title)). + + ## Inside Table + + | Header | Header ([table](./file/b)) | + |-------------------------|-----------------------------------------------------------------| + | Cell [link][link] cell. | Cell `\|` \\| [table](<file\|a> "\|") | + | Cell | Cell cell [table](https://example.com/) cell [table](./file/a). | + MARKDOWN, + '/path/to', + ], + 'images' => [ + <<<'MARKDOWN' + # General + + ![image](<../from/file/a> (title)) + ![image](../from/file/b ' <title> ') + + ![external](https://example.com/) + ![absolute](/path/to/file 'title') + + Text ![external](https://example.com/) text ![image](<../from/file/a> (title)) + text ![image](../from/file/b ' <title> '). + + # Special + + ## Target escaping + + ![image](../from/%3Cfile%3E/%20/a) + + ## Title escaping + + Text ![title](../file/a "title with ( ) and with ' '") text + text ![title](../file/a (title with \( \) and with ' ')) text + text ![title](../file/a "title with ( ) and with ' ' and with \" \""). + + ## Inside Quote + + > ![quote](../from/file/a) + + ## Inside Table + + | Header | Header (![table](../from/file/b)) | + |-------------------------|-------------------------------------------------------------------| + | Cell [link][link] cell. | Cell `\|` \\| ![table](<../from/file\|a> "\|") | + | Cell | Cell cell ![table](https://example.com/) cell ![table](../from/file/a). | + MARKDOWN, + '/path/from', + <<<'MARKDOWN' + # General + + ![image](<./file/a> (title)) + ![image](file/b ' <title> ') + + ![external](https://example.com/) + ![absolute](/path/to/file 'title') + + Text ![external](https://example.com/) text ![image](<./file/a> (title)) + text ![image](file/b ' <title> '). + + # Special + + ## Target escaping + + ![image](./%3Cfile%3E/%20/a) + + ## Title escaping + + Text ![title]( ../file/a "title with ( ) and with ' '" ) text + text ![title]( ../file/a (title with \( \) and with ' ')) text + text ![title](../file/a "title with ( ) and with ' ' and with \" \""). + + ## Inside Quote + + > ![quote](file/a) + + ## Inside Table + + | Header | Header (![table](./file/b)) | + |-------------------------|-------------------------------------------------------------------| + | Cell [link][link] cell. | Cell `\|` \\| ![table](<file\|a> "\|") | + | Cell | Cell cell ![table](https://example.com/) cell ![table](./file/a). | + MARKDOWN, + '/path/to', + ], ]; } // </editor-fold> diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php index 6ae1c36c..8dd84d51 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php @@ -186,7 +186,7 @@ public function setConfiguration(ConfigurationInterface $configuration): void { private function getDelimiterStackLength(DelimiterStack $stack): int { $delimiter = (new ReflectionProperty($stack, 'top'))->getValue($stack); $length = $delimiter instanceof DelimiterInterface - ? $delimiter->getLength() + ? mb_strlen($delimiter->getInlineNode()->getLiteral()) : 0; return $length; diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php index fd99e467..6182ddd8 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php @@ -6,6 +6,7 @@ use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as ReferenceNode; use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Renderer as ReferenceRenderer; use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Extension\CommonMark\Node\Inline\Image; use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\GithubFlavoredMarkdownConverter; use League\CommonMark\Parser\MarkdownParser; @@ -23,6 +24,7 @@ public function testParse(): void { $environment = $converter->getEnvironment() ->addExtension(new Extension()) ->addRenderer(Link::class, new Renderer()) + ->addRenderer(Image::class, new Renderer()) ->addRenderer(ReferenceNode::class, new ReferenceRenderer()); $parser = new MarkdownParser($environment); diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md index bbd71fd7..a79ab55e 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md @@ -36,3 +36,20 @@ text text _[link](https://example.com/)_ text. > | Header | Header | > |--------------------------------------------------|--------| > | Cell `\|` \\| [link](https://example.com/ "\\|") | Cell | + +# Images + +Text text ![image](https://example.com/) text ![image](https://example.com/ "title") +text text ![image][link] text text ![image](https://example.com/) text text text +text text text text text text text text text text text text text text text text text +text text _![image](https://example.com/)_ text. + +![image](https://example.com/) + +![image][link] + +> ![image](https://example.com/) + +| Header | Header | +|--------------------------------|--------| +| ![image](https://example.com/) | Cell | diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml index 7926173e..43c51a46 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml @@ -179,7 +179,7 @@ <table_cell type="data"> <text><![CDATA[Cell ]]></text> <code>|</code> - <text> | </text> + <text><![CDATA[ | ]]></text> <link location="[{38,18,34}]" title="|" url="https://example.com/"> <text>link</text> </link> @@ -191,4 +191,78 @@ </table_section> </table> </block_quote> + <heading level="1"> + <text>Images</text> + </heading> + <paragraph> + <text><![CDATA[Text text ]]></text> + <image location="[{42,10,30}]" title="" url="https://example.com/"> + <text>image</text> + </image> + <text><![CDATA[ text ]]></text> + <image location="[{42,46,38}]" title="title" url="https://example.com/"> + <text>image</text> + </image> + <softbreak/> + <text><![CDATA[text text ]]></text> + <image location="[{43,10,14}]" title="reference" url="https://example.com/"> + <text>image</text> + </image> + <text><![CDATA[ text text ]]></text> + <image location="[{43,35,30}]" title="" url="https://example.com/"> + <text>image</text> + </image> + <text><![CDATA[ text text text]]></text> + <softbreak/> + <text>text text text text text text text text text text text text text text text text text</text> + <softbreak/> + <text><![CDATA[text text ]]></text> + <emph> + <image location="[{45,11,30}]" title="" url="https://example.com/"> + <text>image</text> + </image> + </emph> + <text><![CDATA[ text.]]></text> + </paragraph> + <paragraph> + <image location="[{47,0,30}]" title="" url="https://example.com/"> + <text>image</text> + </image> + </paragraph> + <paragraph> + <image location="[{49,0,14}]" title="reference" url="https://example.com/"> + <text>image</text> + </image> + </paragraph> + <block_quote> + <paragraph> + <image location="[{51,2,30}]" title="" url="https://example.com/"> + <text>image</text> + </image> + </paragraph> + </block_quote> + <table> + <table_section type="head"> + <table_row> + <table_cell type="header"> + <text>Header</text> + </table_cell> + <table_cell type="header"> + <text>Header</text> + </table_cell> + </table_row> + </table_section> + <table_section type="body"> + <table_row> + <table_cell type="data"> + <image location="[{55,2,30}]" title="" url="https://example.com/"> + <text>image</text> + </image> + </table_cell> + <table_cell type="data"> + <text>Cell</text> + </table_cell> + </table_row> + </table_section> + </table> </document> diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php b/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php index 8a0a2acf..02a5c2a7 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php @@ -3,6 +3,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; use LastDragon_ru\LaraASP\Documentator\Markdown\XmlRenderer; +use League\CommonMark\Extension\CommonMark\Node\Inline\Image; use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\Node\Node; use Override; @@ -15,9 +16,9 @@ class Renderer extends XmlRenderer { #[Override] public function getXmlTagName(Node $node): string { - assert($node instanceof Link); + assert($node instanceof Link || $node instanceof Image); - return 'link'; + return $node instanceof Link ? 'link' : 'image'; } /** @@ -25,7 +26,7 @@ public function getXmlTagName(Node $node): string { */ #[Override] public function getXmlAttributes(Node $node): array { - assert($node instanceof Link); + assert($node instanceof Link || $node instanceof Image); return [ 'url' => $this->escape($node->getUrl()), diff --git a/packages/documentator/src/Markdown/Utils.php b/packages/documentator/src/Markdown/Utils.php index b293aaaf..97bcc91a 100644 --- a/packages/documentator/src/Markdown/Utils.php +++ b/packages/documentator/src/Markdown/Utils.php @@ -29,6 +29,26 @@ public static function getContainer(Node $node): ?AbstractBlock { return self::getParent($node, AbstractBlock::class); } + /** + * @template T of Node + * + * @param class-string<T> $class + * + * @return ?T + */ + public static function getChild(Node $node, string $class): ?Node { + $object = null; + + foreach ($node->children() as $child) { + if ($child instanceof $class) { + $object = $child; + break; + } + } + + return $object; + } + public static function getPosition(Node $node): int { $position = 0; From c528350c876db18729c6127484d9ac058d4f49f8 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Mon, 5 Aug 2024 09:23:21 +0400 Subject: [PATCH 18/20] One more test. --- .../documentator/src/Markdown/Document.php | 10 +++++++++ .../src/Markdown/DocumentTest.php | 22 ++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index 36786aea..c6b54b6a 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -274,6 +274,16 @@ public function __toString(): string { private function getRelativeResources(): array { $resources = []; $isRelative = static function (string $target): bool { + // Fast + if (str_starts_with($target, './') || str_starts_with($target, '../')) { + return true; + } elseif (str_starts_with($target, '/')) { + return false; + } else { + // empty + } + + // Long return filter_var($target, FILTER_VALIDATE_URL, FILTER_NULL_ON_FAILURE) === null && !str_starts_with($target, 'tel:+') // see https://www.php.net/manual/en/filter.filters.validate.php && !str_starts_with($target, 'urn:') // see https://www.php.net/manual/en/filter.filters.validate.php diff --git a/packages/documentator/src/Markdown/DocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php index 5a2aa041..8ebeaa67 100644 --- a/packages/documentator/src/Markdown/DocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -206,7 +206,7 @@ public function testSetPath(string $expected, ?string $path, string $content, ?s public static function dataProviderSetPath(): array { return [ // General - 'from `null`' => [ + 'from `null`' => [ <<<'MARKDOWN' [foo]: relative/path/from "title" MARKDOWN, @@ -216,7 +216,7 @@ public static function dataProviderSetPath(): array { MARKDOWN, 'relative/path/to', ], - 'to `null`' => [ + 'to `null`' => [ <<<'MARKDOWN' [foo]: relative/path/from "title" MARKDOWN, @@ -226,7 +226,7 @@ public static function dataProviderSetPath(): array { MARKDOWN, null, ], - 'same' => [ + 'same' => [ <<<'MARKDOWN' [foo]: /path "title" MARKDOWN, @@ -236,7 +236,17 @@ public static function dataProviderSetPath(): array { MARKDOWN, '/path', ], - 'references' => [ + 'query&fragment' => [ + <<<'MARKDOWN' + [foo]: ../from/path?a=123#fragment + MARKDOWN, + '/path/from', + <<<'MARKDOWN' + [foo]: path?a=123#fragment + MARKDOWN, + '/path/to', + ], + 'references' => [ <<<'MARKDOWN' # General @@ -330,7 +340,7 @@ public static function dataProviderSetPath(): array { MARKDOWN, '/path/to', ], - 'links' => [ + 'links' => [ <<<'MARKDOWN' # General @@ -398,7 +408,7 @@ public static function dataProviderSetPath(): array { MARKDOWN, '/path/to', ], - 'images' => [ + 'images' => [ <<<'MARKDOWN' # General From c8b98eab315db6ee4dc1098a0ef208341442eee5 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Mon, 5 Aug 2024 10:00:41 +0400 Subject: [PATCH 19/20] Min version of `league/commonmark` set to `^2.5.1` (where is block's start/end was improved). --- composer.json | 2 +- packages/documentator/composer.json | 2 +- .../src/Markdown/Nodes/Locator/Listener.php | 3 +-- .../Nodes/Locator/ParserTest~expected.xml | 2 +- .../Nodes/Reference/ParserTest~expected.xml | 16 ++++++++-------- 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index 953243f1..1f4da2f4 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "illuminate/testing": "^10.34.0|^11.0.0", "illuminate/translation": "^10.34.0|^11.0.0", "illuminate/validation": "^10.34.0|^11.0.0", - "league/commonmark": "^2.4", + "league/commonmark": "^2.5.1", "mockery/mockery": "^1.6.5", "nikic/php-parser": "^4.18|^5.0", "nuwave/lighthouse": "^6.5.0", diff --git a/packages/documentator/composer.json b/packages/documentator/composer.json index 89d53780..eb39af14 100644 --- a/packages/documentator/composer.json +++ b/packages/documentator/composer.json @@ -25,7 +25,7 @@ "illuminate/console": "^10.34.0|^11.0.0", "illuminate/process": "^10.34.0|^11.0.0", "illuminate/support": "^10.34.0|^11.0.0", - "league/commonmark": "^2.4", + "league/commonmark": "^2.5.1", "nikic/php-parser": "^4.18|^5.0", "phpstan/phpdoc-parser": "^1.25", "symfony/console": "^6.3.0|^7.0.0", diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Listener.php b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php index 887d7388..98a92296 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Listener.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php @@ -88,8 +88,7 @@ private function fixTableSection(Document $document, TableSection $section): voi $start = Cast::toNullable(Table::class, $section->parent())?->getStartLine(); if ($start !== null) { - $start = $start - 1; // Looks like `Table::getStartLine()` is incorrect... - $end = $start + $rows - 1; + $end = $start + $rows - 1; } } diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml index 43c51a46..4c4fd4d9 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml @@ -46,7 +46,7 @@ </emph> <text><![CDATA[ text.]]></text> </paragraph> - <reference destination="https://example.com/" label="link" location="[{12,0,null},{13,0,null}]" title="reference"/> + <reference destination="https://example.com/" label="link" location="[{12,0,null}]" title="reference"/> <heading level="1"> <text>Lists</text> </heading> diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml index 73e58cbd..03a28e94 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml @@ -8,7 +8,7 @@ label="simple:a" destination="https://example.com/" title="" - location="[{5,0,null},{6,0,null}]" + location="[{5,0,null}]" /> <reference label="simple:b" @@ -26,7 +26,7 @@ label="simple:d" destination="file/b" title="title" - location="[{9,0,null},{10,0,null}]" + location="[{9,0,null}]" /> <reference label="simple:e" @@ -38,7 +38,7 @@ title="" label="simple:e" destination="file/b" - location="[{12,0,null},{13,0,null}]" + location="[{12,0,null}]" /> <heading level="1"> <text>Multiline</text> @@ -47,13 +47,13 @@ label="multiline:a" destination="https://example.com/" title="\n1\n2\n3\n" - location="[{16,0,null},{17,0,null},{18,0,null},{19,0,null},{20,0,null},{21,0,null}]" + location="[{16,0,null},{17,0,null},{18,0,null},{19,0,null},{20,0,null}]" /> <reference label="multiline:b" destination="https://example.com/" title="\n example.com\n " - location="[{22,0,null},{23,0,null},{24,0,null},{25,0,null},{26,0,null},{27,0,null}]" + location="[{22,0,null},{23,0,null},{24,0,null},{25,0,null},{26,0,null}]" /> <heading level="1"> <text>Inside Quote</text> @@ -63,13 +63,13 @@ label="quote:a" destination="https://example.com/" title="example.com" - location="[{30,2,null},{31,2,null}]" + location="[{30,2,null}]" /> <reference label="quote:b" destination="https://example.com/" title="" - location="[{32,2,null},{33,2,null},{34,2,null}]" + location="[{32,2,null},{33,2,null}]" /> </block_quote> <block_quote> @@ -78,7 +78,7 @@ label="quote:c" destination="https://example.com/" title="example.com" - location="[{35,4,null},{36,4,null}]" + location="[{35,4,null}]" /> <reference label="quote:d" From 01443b3cb2abfbc218fcb2d15e6d03fb3da95377 Mon Sep 17 00:00:00 2001 From: Aleksei Lebedev <1329824+LastDragon-ru@users.noreply.github.com> Date: Mon, 5 Aug 2024 10:17:13 +0400 Subject: [PATCH 20/20] Dependencies fix. --- composer.json | 1 + packages/documentator/composer.json | 2 ++ 2 files changed, 3 insertions(+) diff --git a/composer.json b/composer.json index 1f4da2f4..3d6e58f6 100644 --- a/composer.json +++ b/composer.json @@ -62,6 +62,7 @@ "illuminate/translation": "^10.34.0|^11.0.0", "illuminate/validation": "^10.34.0|^11.0.0", "league/commonmark": "^2.5.1", + "league/config": "^1.1.1", "mockery/mockery": "^1.6.5", "nikic/php-parser": "^4.18|^5.0", "nuwave/lighthouse": "^6.5.0", diff --git a/packages/documentator/composer.json b/packages/documentator/composer.json index eb39af14..02d4f073 100644 --- a/packages/documentator/composer.json +++ b/packages/documentator/composer.json @@ -19,6 +19,7 @@ }, "require": { "php": "^8.2|^8.3", + "ext-filter": "*", "ext-mbstring": "*", "composer/semver": "^3.2", "illuminate/contracts": "^10.34.0|^11.0.0", @@ -26,6 +27,7 @@ "illuminate/process": "^10.34.0|^11.0.0", "illuminate/support": "^10.34.0|^11.0.0", "league/commonmark": "^2.5.1", + "league/config": "^1.1.1", "nikic/php-parser": "^4.18|^5.0", "phpstan/phpdoc-parser": "^1.25", "symfony/console": "^6.3.0|^7.0.0",