diff --git a/packages/documentator/src/Markdown/Contracts/Mutation.php b/packages/documentator/src/Markdown/Contracts/Mutation.php new file mode 100644 index 000000000..d2ebeb217 --- /dev/null +++ b/packages/documentator/src/Markdown/Contracts/Mutation.php @@ -0,0 +1,14 @@ + + */ + public function __invoke(Document $document, DocumentNode $node): array; +} diff --git a/packages/documentator/src/Markdown/Data/BlockPadding.php b/packages/documentator/src/Markdown/Data/BlockPadding.php new file mode 100644 index 000000000..19a15b554 --- /dev/null +++ b/packages/documentator/src/Markdown/Data/BlockPadding.php @@ -0,0 +1,22 @@ + + */ +readonly class BlockPadding implements Value { + public function __construct( + private int $value, + ) { + // empty + } + + #[Override] + public function get(): mixed { + return $this->value; + } +} diff --git a/packages/documentator/src/Markdown/Data/Length.php b/packages/documentator/src/Markdown/Data/Length.php new file mode 100644 index 000000000..5d011cb98 --- /dev/null +++ b/packages/documentator/src/Markdown/Data/Length.php @@ -0,0 +1,22 @@ + + */ +readonly class Length implements Value { + public function __construct( + private int $value, + ) { + // empty + } + + #[Override] + public function get(): mixed { + return $this->value; + } +} diff --git a/packages/documentator/src/Markdown/Document.php b/packages/documentator/src/Markdown/Document.php index c6b54b6a7..bec21132f 100644 --- a/packages/documentator/src/Markdown/Document.php +++ b/packages/documentator/src/Markdown/Document.php @@ -4,44 +4,37 @@ use Closure; use LastDragon_ru\LaraASP\Core\Utils\Path; +use LastDragon_ru\LaraASP\Documentator\Markdown\Contracts\Mutation; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; -use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Location as LocationData; -use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Location; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; +use LastDragon_ru\LaraASP\Documentator\Markdown\Mutations\FootnotesPrefix; +use LastDragon_ru\LaraASP\Documentator\Markdown\Mutations\FootnotesRemove; +use LastDragon_ru\LaraASP\Documentator\Markdown\Mutations\ReferencesInline; +use LastDragon_ru\LaraASP\Documentator\Markdown\Mutations\ReferencesPrefix; +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\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; use Stringable; -use function array_slice; +use function array_key_last; use function count; -use function filter_var; use function implode; +use function is_int; use function ltrim; -use function mb_substr; -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; -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 @@ -49,6 +42,7 @@ class Document implements Stringable { private DocumentNode $node; private ?MarkdownParser $parser = null; + private ?Editor $editor = null; private ?string $path = null; private ?string $title = null; private ?string $summary = null; @@ -63,135 +57,109 @@ public function isEmpty(): bool { } /** - * Returns the first `# Header` if present. + * Returns the first `# Header` if present, the title based on filename + * if known, or `null`. */ 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; + $title = $this->getFirstNode(Heading::class, static fn ($n) => $n->getLevel() === 1); + $title = $this->getBlockText($title) ?? Text::getPathTitle((string) $this->getPath()); + $title = trim(ltrim("{$title}", '#')); $this->title = $title; } - return $this->title; + return $this->title ?: null; } /** - * Returns the first paragraph right after `# Header` if present. + * Returns the first paragraph if present. */ public function getSummary(): ?string { if ($this->summary === null) { - $title = $this->getFirstNode($this->node, Heading::class, static fn ($n) => $n->getLevel() === 1); - $summary = $this->getText($this->getFirstNode($title?->next(), Paragraph::class)); - $summary = trim("{$summary}") ?: null; + $summary = $this->getSummaryNode(); + $summary = $this->getBlockText($summary); + $summary = trim("{$summary}"); $this->summary = $summary; } - return $this->summary; + return $this->summary ?: null; + } + + /** + * Returns the rest of the document text after the summary. + */ + public function getBody(): ?string { + $summary = $this->getSummaryNode(); + $start = $summary?->getEndLine(); + $end = array_key_last($this->getLines()); + $body = $start !== null && is_int($end) + ? $this->getText(new Locator($start + 1, $end)) + : null; + $body = trim((string) $body) ?: null; + + return $body; } 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; + $this->path = $path ? Path::normalize($path) : null; - return $this; - } - - // Same? - $path = Path::getPath($this->path, $path); + return $this; + } - if ($this->path === $path) { - return $this; - } + public function getText(Location|Coordinate $location): ?string { + return $this->getEditor()->getText($location); + } - // Update - $resources = $this->getRelativeResources(); - $changes = []; - $editor = new Editor(); - $lines = $this->getLines(); - $path = Path::normalize($path); + /** + * @return new + */ + public function mutate(Mutation ...$mutations): static { + $document = clone $this; - foreach ($resources as $resource) { - // Location? - $location = Data::get($resource, LocationData::class); + foreach ($mutations as $mutation) { + $changes = $mutation($document, $document->node); - if (!$location) { + if (!$changes) { continue; } - // Update - $text = null; - $origin = trim((string) $editor->getText($lines, $location)); - - 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*getLabel(); - $title = $resource->getTitle(); - $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+getEditor()->mutate($changes); + $document = clone $document->setContent($content); } - // Update - if ($changes) { - $lines = $editor->modify($lines, $changes); - $content = implode("\n", $lines); - - $this->setContent($content); - } + return $document; + } - $this->path = $path; + /** + * Renames all references/footnotes/etc to make possible inline the + * document into another document without conflicts/ambiguities. + * + * @return new + */ + public function toInlinable(?string $seed = null): static { + return $this->mutate(new FootnotesPrefix($seed), new ReferencesPrefix($seed)); + } - // Return - return $this; + /** + * Inlines all references, removes footnotes, etc, to make possible + * extract any block/paragraph from the document without losing + * information. + * + * @return new + */ + public function toSplittable(): static { + return $this->mutate(new FootnotesRemove(), new ReferencesInline()); } protected function setContent(string $content): static { $this->node = $this->parse($content); $this->title = null; $this->summary = null; + $this->editor = null; return $this; } @@ -213,100 +181,77 @@ protected function getLines(): array { return Data::get($this->node, Lines::class) ?? []; } - protected function getText(?AbstractBlock $node): ?string { - if ($node?->getStartLine() === null || $node->getEndLine() === null) { - return null; + protected function getEditor(): Editor { + if ($this->editor === null) { + $this->editor = new Editor($this->getLines()); } - $start = $node->getStartLine() - 1; - $end = $node->getEndLine() - 1; - $lines = array_slice($this->getLines(), $start, $end - $start + 1); - $text = implode("\n", $lines); - - return $text; + return $this->editor; } /** * @template T of Node * - * @param class-string $class - * @param Closure(T): bool $filter + * @param class-string $class + * @param Closure(T): bool|null $filter + * @param Closure(Node): bool|null $skip * * @return ?T */ - protected function getFirstNode(?Node $node, string $class, ?Closure $filter = null): ?Node { - // Null? - if ($node === null) { - return null; - } + private function getFirstNode(string $class, ?Closure $filter = null, ?Closure $skip = null): ?Node { + $node = null; + + foreach ($this->node->children() as $child) { + // Comment? + if ( + $child instanceof HtmlBlock + && str_starts_with($child->getLiteral(), '') + ) { + continue; + } - // Wanted? - if ($node instanceof $class && ($filter === null || $filter($node))) { - return $node; - } + // Skipped? + if ($skip !== null && $skip($child)) { + continue; + } - // Comment? - if ( - $node instanceof HtmlBlock - && str_starts_with($node->getLiteral(), '') - ) { - return $this->getFirstNode($node->next(), $class, $filter); - } + // Wanted? + if ($child instanceof $class) { + if ($filter === null || $filter($child)) { + $node = $child; + } - // Document? - if ($node instanceof DocumentNode) { - return $this->getFirstNode($node->firstChild(), $class, $filter); + break; + } + + // End + break; } - // Not found - return null; + return $node; } - #[Override] - public function __toString(): string { - return implode("\n", $this->getLines()); + private function getBlockText(?AbstractBlock $node): ?string { + $location = $node?->getStartLine() !== null && $node->getEndLine() !== null + ? new Locator($node->getStartLine(), $node->getEndLine()) + : null; + $text = $location + ? $this->getText($location) + : null; + + return $text; } - /** - * @return list - */ - 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 - } + private function getSummaryNode(): ?Paragraph { + $skip = static fn ($node) => $node instanceof Heading && $node->getLevel() === 1; + $node = $this->getFirstNode(Paragraph::class, skip: $skip); - // 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 - && Path::isRelative($target); - }; - - foreach ($this->node->iterator() as $node) { - // 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; - } - } - - // Reference - // => we need only which are relative - if ($node instanceof Reference && $isRelative($node->getDestination())) { - $resources[] = $node; - } - } + return $node; + } - return $resources; + #[Override] + public function __toString(): string { + return implode("\n", $this->getLines()); } } diff --git a/packages/documentator/src/Markdown/DocumentTest.php b/packages/documentator/src/Markdown/DocumentTest.php index 8ebeaa675..1878d7756 100644 --- a/packages/documentator/src/Markdown/DocumentTest.php +++ b/packages/documentator/src/Markdown/DocumentTest.php @@ -2,17 +2,17 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Documentator\Markdown\Contracts\Mutation; use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Node\Block\Document as DocumentNode; +use Mockery; 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( @@ -68,6 +68,16 @@ public function testGetTitle(): void { )) ->getTitle(), ); + self::assertEquals( + 'File Name', + (new Document( + <<<'MARKDOWN' + fsdfsdfsdf + MARKDOWN, + 'path/to/FileName.txt', + )) + ->getTitle(), + ); } public function testGetSummary(): void { @@ -82,7 +92,8 @@ public function testGetSummary(): void { )) ->getSummary(), ); - self::assertNull( + self::assertEquals( + 'fsdfsdfsdf', (new Document( <<<'MARKDOWN' fsdfsdfsdf @@ -154,335 +165,167 @@ public function testGetSummary(): void { ); } - public function testIsEmpty(): void { - self::assertFalse( + public function testGetBody(): void { + self::assertNull( (new Document( <<<'MARKDOWN' - fsdfsdfsdf - fsdfsdfsdf + ## Header A + # Header B + + sdfsdfsdf MARKDOWN, )) - ->isEmpty(), + ->getBody(), ); - self::assertFalse( + self::assertEquals( + <<<'MARKDOWN' + # Header + + sdfsdfsdf + MARKDOWN, (new Document( <<<'MARKDOWN' - [unused]: ../path/to/file + fsdfsdfsdf + + # Header + + sdfsdfsdf MARKDOWN, )) - ->isEmpty(), + ->getBody(), ); - self::assertFalse( + self::assertNull( (new Document( <<<'MARKDOWN' - + # Header + + > Not a paragraph + + fsdfsdfsdf + + text text text MARKDOWN, )) - ->isEmpty(), + ->getBody(), ); - self::assertTrue( + self::assertEquals( + <<<'MARKDOWN' + text text text + + text text text + MARKDOWN, (new Document( <<<'MARKDOWN' + # + fsdfsdfsdf + text text text + text text text MARKDOWN, )) - ->isEmpty(), + ->getBody(), ); - } + self::assertEquals( + <<<'MARKDOWN' + text text text - #[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', - ], - 'query&fragment' => [ - <<<'MARKDOWN' - [foo]: ../from/path?a=123#fragment - MARKDOWN, - '/path/from', - <<<'MARKDOWN' - [foo]: path?a=123#fragment - MARKDOWN, - '/path/to', - ], - 'references' => [ + text text text + MARKDOWN, + (new Document( <<<'MARKDOWN' - # General - - [tel]: tel:+70000000000 "title" - [link]: ../from/file/a - [link]: ../from/file/b ' ' - [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 - ## Target escaping - - [title]: ../from/%3Cfile%3E/%20/a - [title]: <../from/file/ /a> - - ## Title escaping + # Header - [title]: ../file/a "title with ( ) and with ' '" - [title]: ../file/a (title with \( \) and with ' ') - [title]: ../file/a "title with ( ) and with ' ' and with \" \"" + fsdfsdfsdf + fsdfsdfsdf - ## Inside Quote + text text text - > [quote]: ../file/a - > - > [quote]: ../from/file/b (title) + text text text 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 - - ## Target escaping - - [title]: ./%3Cfile%3E/%20/a - [title]: <./file/ /a> - - ## Title escaping - - [title]: ../file/a "title with ( ) and with ' '" - [title]: ../file/a (title with \( \) and with ' ') - [title]: ../file/a "title with ( ) and with ' ' and with \" \"" - - ## Inside Quote + )) + ->getBody(), + ); + self::assertEquals( + <<<'MARKDOWN' + <!-- Comment --> - > [quote]: ../file/a - > - > [quote]: - > ./file/b - > (title) - MARKDOWN, - '/path/to', - ], - 'links' => [ + text text text + MARKDOWN, + (new Document( <<<'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>). + <!-- Comment --> - ## Title escaping + # Header - 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 \" \""). + <!-- Comment --> - ## Inside Quote + text text text - > Text [quote](../file/a) text [quote](https://example.com/) - > text text [quote](../from/file/b (title)). + <!-- Comment --> - ## Inside Table + text text text + MARKDOWN, + )) + ->getBody(), + ); + } - | 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). | + public function testIsEmpty(): void { + self::assertFalse( + (new Document( + <<<'MARKDOWN' + fsdfsdfsdf + fsdfsdfsdf MARKDOWN, - '/path/from', + )) + ->isEmpty(), + ); + self::assertFalse( + (new Document( <<<'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). | + [unused]: ../path/to/file MARKDOWN, - '/path/to', - ], - 'images' => [ + )) + ->isEmpty(), + ); + self::assertFalse( + (new Document( <<<'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). | + <!-- comment --> MARKDOWN, - '/path/from', + )) + ->isEmpty(), + ); + self::assertTrue( + (new Document( <<<'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', - ], - ]; + )) + ->isEmpty(), + ); + } + + public function testMutate(): void { + $document = new Document(''); + $mutation = Mockery::mock(Mutation::class); + $mutation + ->shouldReceive('__invoke') + ->with(Mockery::type(Document::class), Mockery::type(DocumentNode::class)) + ->once() + ->andReturn([ + // empty + ]); + + $clone = clone $document; + $mutated = $document->mutate($mutation); + + self::assertNotSame($document, $mutated); + self::assertEquals($clone, $document); } - // </editor-fold> } diff --git a/packages/documentator/src/Markdown/Editor.php b/packages/documentator/src/Markdown/Editor.php index 3efad7b83..80f20f138 100644 --- a/packages/documentator/src/Markdown/Editor.php +++ b/packages/documentator/src/Markdown/Editor.php @@ -5,6 +5,8 @@ use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Coordinate; use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Location; use LastDragon_ru\LaraASP\Documentator\Utils\Text; +use Override; +use Stringable; use function array_key_last; use function array_merge; @@ -21,21 +23,40 @@ /** * @internal */ -class Editor { - public function __construct() { +class Editor implements Stringable { + public function __construct( + /** + * @var array<int, string> + */ + private array $lines, + ) { // empty } + #[Override] + public function __toString(): string { + return implode("\n", $this->lines); + } + /** - * @param array<int, string> $lines + * @return array<int, string> */ - public function getText(array $lines, Location $location): ?string { + public function getLines(): array { + return $this->lines; + } + + public function getText(Location|Coordinate $location): ?string { + // Coordinate? + if ($location instanceof Coordinate) { + $location = [$location]; + } + // Select $selected = null; foreach ($location as $coordinate) { - if (isset($lines[$coordinate->line])) { - $selected[] = mb_substr($lines[$coordinate->line], $coordinate->offset, $coordinate->length); + if (isset($this->lines[$coordinate->line])) { + $selected[] = mb_substr($this->lines[$coordinate->line], $coordinate->offset, $coordinate->length); } else { $selected = null; break; @@ -51,13 +72,13 @@ public function getText(array $lines, Location $location): ?string { } /** - * @param array<int, string> $lines * @param array<array-key, array{Location, ?string}> $changes * - * @return array<int, string> + * @return new<static> */ - public function modify(array $lines, array $changes): array { + public function mutate(array $changes): static { // Modify + $lines = $this->lines; $changes = $this->removeOverlaps($changes); $changes = $this->expand($changes); $paddings = []; @@ -106,7 +127,10 @@ public function modify(array $lines, array $changes): array { } // Return - return $lines; + $editor = clone $this; + $editor->lines = $lines; + + return $editor; } /** diff --git a/packages/documentator/src/Markdown/EditorTest.php b/packages/documentator/src/Markdown/EditorTest.php index 8cbaf72b2..28b90c039 100644 --- a/packages/documentator/src/Markdown/EditorTest.php +++ b/packages/documentator/src/Markdown/EditorTest.php @@ -13,8 +13,7 @@ */ #[CoversClass(Editor::class)] final class EditorTest extends TestCase { - public function testModify(): void { - $editor = new Editor(); + public function testMutate(): void { $lines = [ 1 => 'a b c d', 2 => 'e f g h', @@ -32,6 +31,7 @@ public function testModify(): void { 14 => '> i j k l', 15 => '>', ]; + $editor = new Editor($lines); $changes = [ [new Locator(1, 1, 2, 3), '123'], [new Locator(2, 4, 4, 4), '123'], @@ -39,6 +39,7 @@ public function testModify(): void { [new Locator(11, 12, 4, 3, 2), "123\n345"], [new Locator(14, 15, 4, 3, 2), '123'], ]; + $actual = $editor->mutate($changes); $expected = [ 1 => 'a 123 d', 2 => 'e f 123', @@ -56,11 +57,13 @@ public function testModify(): void { 14 => '> i j 123', ]; - self::assertSame($expected, $editor->modify($lines, $changes)); + self::assertNotSame($editor, $actual); + self::assertEquals($lines, $editor->getLines()); + self::assertSame($expected, $actual->getLines()); } public function testRemoveOverlaps(): void { - $editor = new class() extends Editor { + $editor = new class([]) extends Editor { /** * @inheritDoc */ @@ -89,7 +92,7 @@ public function removeOverlaps(array $changes): array { } public function testExpand(): void { - $editor = new class() extends Editor { + $editor = new class([]) extends Editor { /** * @inheritDoc */ @@ -117,8 +120,7 @@ public function expand(array $changes): array { } public function testGetText(): void { - $editor = new Editor(); - $lines = [ + $editor = new Editor([ 0 => 'a b c d', 1 => 'e f g h', 2 => 'i j k l', @@ -126,10 +128,10 @@ public function testGetText(): void { 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::assertNull($editor->getText(new Locator(25, 25, 0))); + self::assertEquals('f g', $editor->getText(new Locator(1, 1, 2, 3))); self::assertEquals( <<<'TEXT' k l @@ -137,7 +139,13 @@ public function testGetText(): void { q r s TEXT, - $editor->getText($lines, new Locator(2, 5, 4, 5)), + $editor->getText(new Locator(2, 5, 4, 5)), + ); + self::assertEquals( + <<<'TEXT' + f g + TEXT, + $editor->getText(new Coordinate(1, 2, 3)), ); } } diff --git a/packages/documentator/src/Markdown/Extension.php b/packages/documentator/src/Markdown/Extension.php index 802d9689c..2dba77e6a 100644 --- a/packages/documentator/src/Markdown/Extension.php +++ b/packages/documentator/src/Markdown/Extension.php @@ -13,6 +13,8 @@ use League\CommonMark\Event\DocumentPreParsedEvent; use League\CommonMark\Extension\CommonMark\Parser\Inline\CloseBracketParser; use League\CommonMark\Extension\ExtensionInterface; +use League\CommonMark\Extension\Footnote\FootnoteExtension; +use League\CommonMark\Extension\Footnote\Parser\FootnoteRefParser; use Override; /** @@ -34,8 +36,10 @@ public function register(EnvironmentBuilderInterface $environment): void { $referenceParser = new ReferenceParser(); $environment + ->addExtension(new FootnoteExtension()) ->addBlockStartParser($referenceParser) ->addInlineParser(new Parser(new CloseBracketParser()), 100) + ->addInlineParser(new Parser(new FootnoteRefParser()), 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 09c0fa028..ed77c0fc5 100644 --- a/packages/documentator/src/Markdown/ExtensionTest.php +++ b/packages/documentator/src/Markdown/ExtensionTest.php @@ -6,8 +6,6 @@ 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; use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\GithubFlavoredMarkdownConverter; @@ -25,8 +23,7 @@ final class ExtensionTest extends TestCase { public function testExtension(): void { $converter = new GithubFlavoredMarkdownConverter(); $environment = $converter->getEnvironment() - ->addExtension(new Extension()) - ->addRenderer(Block::class, new Renderer()); + ->addExtension(new Extension()); $parser = new MarkdownParser($environment); $markdown = "# Header\nParagraph [link](https://example.com/)."; diff --git a/packages/documentator/src/Markdown/Location/Coordinate.php b/packages/documentator/src/Markdown/Location/Coordinate.php index 9401f82cd..59ab46b2e 100644 --- a/packages/documentator/src/Markdown/Location/Coordinate.php +++ b/packages/documentator/src/Markdown/Location/Coordinate.php @@ -2,9 +2,6 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Location; -/** - * @internal - */ readonly class Coordinate { public function __construct( public int $line, diff --git a/packages/documentator/src/Markdown/Location/Locator.php b/packages/documentator/src/Markdown/Location/Locator.php index 60323e1df..44fd51294 100644 --- a/packages/documentator/src/Markdown/Location/Locator.php +++ b/packages/documentator/src/Markdown/Location/Locator.php @@ -12,7 +12,7 @@ public function __construct( private int $startLine, private int $endLine, - private int $offset, + private int $offset = 0, private ?int $length = null, private int $padding = 0, ) { diff --git a/packages/documentator/src/Markdown/Mutations/FootnotesPrefix.php b/packages/documentator/src/Markdown/Mutations/FootnotesPrefix.php new file mode 100644 index 000000000..0a5e9bb92 --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/FootnotesPrefix.php @@ -0,0 +1,98 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Documentator\Markdown\Contracts\Mutation; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Location; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; +use League\CommonMark\Extension\Footnote\Node\Footnote; +use League\CommonMark\Extension\Footnote\Node\FootnoteRef; +use League\CommonMark\Node\Block\Document as DocumentNode; +use Override; + +use function hash; +use function mb_strlen; +use function mb_substr; +use function uniqid; + +/** + * Adds unique prefix for all footnotes. + */ +readonly class FootnotesPrefix implements Mutation { + public function __construct( + /** + * If the prefix is not specified, the hash of the document path will + * be used. If the document path is unknown, the random hash will be + * used. + */ + protected ?string $prefix = null, + ) { + // empty + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(Document $document, DocumentNode $node): array { + $prefix = $this->prefix ?: hash('xxh3', $document->getPath() ?: uniqid($this::class)); // @phpstan-ignore disallowed.function + $changes = []; + + foreach ($node->iterator() as $child) { + // Footnote? + if (!($child instanceof FootnoteRef) && !($child instanceof Footnote)) { + continue; + } + + // Replace + $label = $this->getLabel($document, $child); + $location = $label ? $this->getLabelLocation($child, $label) : null; + + if ($location) { + $changes[] = [$location, "{$prefix}-{$label}"]; + } + } + + return $changes; + } + + private function getLabel(Document $document, Footnote|FootnoteRef $footnote): ?string { + // The thephpleague/commonmark replaces the original title of + // `FootnoteRef` to make it unique. We need to find original. + $label = $footnote->getReference()->getLabel(); + + if ($footnote instanceof FootnoteRef) { + $location = Utils::getLocation($footnote); + $label = $location + ? (mb_substr($document->getText($location) ?? '', 2, -1) ?: '') + : null; + } + + return $label; + } + + private function getLabelLocation(Footnote|FootnoteRef $footnote, string $label): ?Location { + // Get the start line + $location = Utils::getLocation($footnote); + $coordinate = null; + + foreach ($location ?? [] as $c) { + $coordinate = $c; + break; + } + + if ($coordinate === null) { + return null; + } + + // Location + $startLine = $coordinate->line; + $endLine = $startLine; + $offset = $coordinate->offset + 2; + $length = mb_strlen($label); + + return new Locator($startLine, $endLine, $offset, $length); + } +} diff --git a/packages/documentator/src/Markdown/Mutations/FootnotesPrefixTest.php b/packages/documentator/src/Markdown/Mutations/FootnotesPrefixTest.php new file mode 100644 index 000000000..ca8d9e812 --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/FootnotesPrefixTest.php @@ -0,0 +1,112 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Editor; +use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Node\Block\Document as DocumentNode; +use PHPUnit\Framework\Attributes\CoversClass; +use ReflectionProperty; + +/** + * @internal + */ +#[CoversClass(FootnotesPrefix::class)] +final class FootnotesPrefixTest extends TestCase { + private const Markdown = <<<'MARKDOWN' + # Header[^1] + + Text text text[^2] text text [^1] text text text [^2] text text text + text text[^1] text text text [^2] text text text [^3] text[^bignote]. + + [^1]: footnote 1 + + Text text text[^2]. + + [^2]: footnote 2 + + [^4]: footnote 4 + + [^bignote]: 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. + + 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 text + text. + MARKDOWN; + + public function testInvoke(): void { + $document = new Document(self::Markdown, 'path/to/file.md'); + $node = Cast::to(DocumentNode::class, (new ReflectionProperty($document, 'node'))->getValue($document)); + $lines = Data::get($node, Lines::class) ?? []; + $mutation = new FootnotesPrefix(); + $changes = $mutation($document, $node); + $actual = (string) (new Editor($lines))->mutate($changes); + + self::assertEquals( + <<<'MARKDOWN' + # Header[^a282e9c32e7eee65-1] + + Text text text[^a282e9c32e7eee65-2] text text [^a282e9c32e7eee65-1] text text text [^a282e9c32e7eee65-2] text text text + text text[^a282e9c32e7eee65-1] text text text [^a282e9c32e7eee65-2] text text text [^3] text[^a282e9c32e7eee65-bignote]. + + [^a282e9c32e7eee65-1]: footnote 1 + + Text text text[^a282e9c32e7eee65-2]. + + [^a282e9c32e7eee65-2]: footnote 2 + + [^4]: footnote 4 + + [^a282e9c32e7eee65-bignote]: 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. + + 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 text + text. + MARKDOWN, + $actual, + ); + } + + public function testInvokeExplicit(): void { + $document = new Document(self::Markdown, __FILE__); + $node = Cast::to(DocumentNode::class, (new ReflectionProperty($document, 'node'))->getValue($document)); + $lines = Data::get($node, Lines::class) ?? []; + $mutation = new FootnotesPrefix('prefix'); + $changes = $mutation($document, $node); + $actual = (string) (new Editor($lines))->mutate($changes); + + self::assertEquals( + <<<'MARKDOWN' + # Header[^prefix-1] + + Text text text[^prefix-2] text text [^prefix-1] text text text [^prefix-2] text text text + text text[^prefix-1] text text text [^prefix-2] text text text [^3] text[^prefix-bignote]. + + [^prefix-1]: footnote 1 + + Text text text[^prefix-2]. + + [^prefix-2]: footnote 2 + + [^4]: footnote 4 + + [^prefix-bignote]: 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. + + 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 text + text. + MARKDOWN, + $actual, + ); + } +} diff --git a/packages/documentator/src/Markdown/Mutations/FootnotesRemove.php b/packages/documentator/src/Markdown/Mutations/FootnotesRemove.php new file mode 100644 index 000000000..46c58d08c --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/FootnotesRemove.php @@ -0,0 +1,41 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Documentator\Markdown\Contracts\Mutation; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; +use League\CommonMark\Extension\Footnote\Node\Footnote; +use League\CommonMark\Extension\Footnote\Node\FootnoteRef; +use League\CommonMark\Node\Block\Document as DocumentNode; +use Override; + +/** + * Removes all footnotes. + */ +readonly class FootnotesRemove implements Mutation { + public function __construct() { + // empty + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(Document $document, DocumentNode $node): array { + $changes = []; + + foreach ($node->iterator() as $child) { + $location = match (true) { + $child instanceof FootnoteRef, $child instanceof Footnote => Utils::getLocation($child), + default => null, + }; + + if ($location) { + $changes[] = [$location, null]; + } + } + + return $changes; + } +} diff --git a/packages/documentator/src/Markdown/Mutations/FootnotesRemoveTest.php b/packages/documentator/src/Markdown/Mutations/FootnotesRemoveTest.php new file mode 100644 index 000000000..d3853a956 --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/FootnotesRemoveTest.php @@ -0,0 +1,65 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Editor; +use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Node\Block\Document as DocumentNode; +use PHPUnit\Framework\Attributes\CoversClass; +use ReflectionProperty; + +/** + * @internal + */ +#[CoversClass(FootnotesRemove::class)] +final class FootnotesRemoveTest extends TestCase { + public function testInvoke(): void { + $document = new Document( + <<<'MARKDOWN' + # Header[^1] + + Text text text[^2] text text [^1] text text text [^2] text text text + text text[^1] text text text [^2] text text text [^3] text[^bignote]. + + [^1]: footnote 1 + + Text text text[^2]. + + [^2]: footnote 2 + + [^4]: footnote 4 + + [^bignote]: 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. + + 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 text + text. + MARKDOWN, + ); + $node = Cast::to(DocumentNode::class, (new ReflectionProperty($document, 'node'))->getValue($document)); + $lines = Data::get($node, Lines::class) ?? []; + $mutation = new FootnotesRemove(); + $changes = $mutation($document, $node); + $actual = (string) (new Editor($lines))->mutate($changes); + + self::assertEquals( + <<<'MARKDOWN' + # Header + + Text text text text text text text text text text text + text text text text text text text text [^3] text. + + Text text text. + + [^4]: footnote 4 + MARKDOWN, + $actual, + ); + } +} diff --git a/packages/documentator/src/Markdown/Mutations/Move.php b/packages/documentator/src/Markdown/Mutations/Move.php new file mode 100644 index 000000000..fbdac694d --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/Move.php @@ -0,0 +1,172 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Core\Utils\Path; +use LastDragon_ru\LaraASP\Documentator\Markdown\Contracts\Mutation; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; +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\Node\Block\Document as DocumentNode; +use League\CommonMark\Node\Inline\Text; +use Override; + +use function dirname; +use function filter_var; +use function mb_substr; +use function preg_match; +use function preg_quote; +use function rawurldecode; +use function rtrim; +use function str_replace; +use function str_starts_with; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_URL; + +/** + * Changes path and updates all relative links. + * + * Please note that links may/will be reformatted (because there is no + * information about their original form) + */ +readonly class Move implements Mutation { + public function __construct( + protected string $path, + ) { + // empty + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(Document $document, DocumentNode $node): array { + // No path? + $docPath = $document->getPath(); + + if ($docPath === null) { + $document->setPath(Path::normalize($this->path)); + + return []; + } + + // Same? + $docDirectory = dirname($docPath); + $newPath = Path::getPath($docDirectory, $this->path); + + if ($docPath === $newPath) { + return []; + } + + // Update + $changes = []; + $resources = $this->getRelativeResources($node); + $newDirectory = dirname($newPath); + + foreach ($resources as $resource) { + // Location? + $location = Utils::getLocation($resource); + + if (!$location) { + continue; + } + + // Changes + $text = null; + $origin = trim((string) $document->getText($location)); + + if ($resource instanceof Link || $resource instanceof Image) { + $title = (string) $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($docDirectory, $target); + $target = Path::getRelativePath($newDirectory, $target); + $targetWrap = (bool) preg_match('/^!?\['.preg_quote($label, '/').']\(\s*</u', $origin); + + if (Utils::getContainer($resource) instanceof TableCell) { + $title = str_replace('|', '\\|', $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(); + $titleWrapper = mb_substr($origin, -1, 1); + $target = rawurldecode($resource->getDestination()); + $target = Path::getPath($docDirectory, $target); + $target = Path::getRelativePath($newDirectory, $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 + } + + if ($text !== null) { + $changes[] = [$location, $text]; + } + } + + // Set + $document->setPath($newPath); + + // Return + return $changes; + } + + /** + * @return list<AbstractWebResource|Reference> + */ + protected function getRelativeResources(DocumentNode $node): 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 + && Path::isRelative($target); + }; + + foreach ($node->iterator() as $child) { + // Resource? + // => we need only which are relative + // => we don't need references + if ($child instanceof AbstractWebResource) { + if (!Utils::isReference($child) && $isRelative($child->getUrl())) { + $resources[] = $child; + } + } + + // Reference + // => we need only which are relative + if ($child instanceof Reference && $isRelative($child->getDestination())) { + $resources[] = $child; + } + } + + return $resources; + } +} diff --git a/packages/documentator/src/Markdown/Mutations/MoveTest.php b/packages/documentator/src/Markdown/Mutations/MoveTest.php new file mode 100644 index 000000000..ea3d5716a --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/MoveTest.php @@ -0,0 +1,355 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Editor; +use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Node\Block\Document as DocumentNode; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; + +/** + * @internal + */ +#[CoversClass(Move::class)] +final class MoveTest extends TestCase { + // <editor-fold desc="Tests"> + // ========================================================================= + #[DataProvider('dataProviderInvoke')] + public function testInvoke(string $expected, ?string $path, string $content, string $target): void { + $mutation = new Move($target); + $document = new Document($content, $path); + $node = Cast::to(DocumentNode::class, (new ReflectionProperty($document, 'node'))->getValue($document)); + $lines = Data::get($node, Lines::class) ?? []; + $changes = $mutation($document, $node); + $actual = (string) (new Editor($lines))->mutate($changes); + + self::assertEquals($expected, $actual); + } + //</editor-fold> + + // <editor-fold desc="DataProviders"> + // ========================================================================= + /** + * @return array<string, array{string, ?string, string, string}> + */ + public static function dataProviderInvoke(): array { + return [ + // General + 'from `null`' => [ + <<<'MARKDOWN' + [foo]: relative/path/from "title" + MARKDOWN, + null, + <<<'MARKDOWN' + [foo]: relative/path/from "title" + MARKDOWN, + 'relative/path/to/file.md', + ], + 'same' => [ + <<<'MARKDOWN' + [foo]: /path "title" + MARKDOWN, + '/path/file.md', + <<<'MARKDOWN' + [foo]: /path "title" + MARKDOWN, + '/path/file.md', + ], + 'query&fragment' => [ + <<<'MARKDOWN' + [foo]: ../from/path?a=123#fragment + MARKDOWN, + '/path/from/file.md', + <<<'MARKDOWN' + [foo]: path?a=123#fragment + MARKDOWN, + '/path/to/file.md', + ], + '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 + + ## Target escaping + + [title]: ../from/%3Cfile%3E/%20/a + [title]: <../from/file/ /a> + + ## Title escaping + + [title]: ../file/a "title with ( ) and with ' '" + [title]: ../file/a (title with \( \) and with ' ') + [title]: ../file/a "title with ( ) and with ' ' and with \" \"" + + ## Inside Quote + + > [quote]: ../file/a + > + > [quote]: ../from/file/b (title) + MARKDOWN, + '/path/from/file.md', + <<<'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 + + ## Target escaping + + [title]: ./%3Cfile%3E/%20/a + [title]: <./file/ /a> + + ## Title escaping + + [title]: ../file/a "title with ( ) and with ' '" + [title]: ../file/a (title with \( \) and with ' ') + [title]: ../file/a "title with ( ) and with ' ' and with \" \"" + + ## Inside Quote + + > [quote]: ../file/a + > + > [quote]: + > ./file/b + > (title) + MARKDOWN, + '/path/to/file.md', + ], + '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/file.md', + <<<'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/file.md', + ], + '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/file.md', + <<<'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/file.md', + ], + 'footnotes' => [ + <<<'MARKDOWN' + # General + + Text text[^1] text text[^note] text[^quote]. + + # Special + + ## Inside Quote + + > Text text[^1] text text[^note] text[^quote]. + > + > [^quote]: Text text [tel](tel:+70000000000 "title") text [link](../from/file/a) + > text [absolute](/path/to/file 'title') text [link](../from/file/b) + + [^1]: Text text text [link](../from/file/a) + + [^note]: Text text [tel](tel:+70000000000 "title") text [link](../from/file/a) + text [absolute](/path/to/file 'title') text [link](../from/file/b) + MARKDOWN, + '/path/from/file.md', + <<<'MARKDOWN' + # General + + Text text[^1] text text[^note] text[^quote]. + + # Special + + ## Inside Quote + + > Text text[^1] text text[^note] text[^quote]. + > + > [^quote]: Text text [tel](tel:+70000000000 "title") text [link](./file/a) + > text [absolute](/path/to/file 'title') text [link](file/b) + + [^1]: Text text text [link](./file/a) + + [^note]: Text text [tel](tel:+70000000000 "title") text [link](./file/a) + text [absolute](/path/to/file 'title') text [link](file/b) + MARKDOWN, + '/path/to/file.md', + ], + ]; + } + // </editor-fold> +} diff --git a/packages/documentator/src/Markdown/Mutations/ReferencesInline.php b/packages/documentator/src/Markdown/Mutations/ReferencesInline.php new file mode 100644 index 000000000..b9b0db4c5 --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/ReferencesInline.php @@ -0,0 +1,98 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Documentator\Markdown\Contracts\Mutation; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; +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\Node\Block\Document as DocumentNode; +use League\CommonMark\Node\Inline\Text; +use Override; + +use function rawurldecode; +use function str_replace; + +/** + * Inlines all references. + */ +class ReferencesInline implements Mutation { + public function __construct() { + // empty + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(Document $document, DocumentNode $node): array { + $changes = []; + $references = $this->getReferences($node); + + foreach ($references as $reference) { + // Location? + $location = Utils::getLocation($reference); + + if (!$location) { + continue; + } + + // Change + $text = null; + + if ($reference instanceof Link || $reference instanceof Image) { + $title = (string) $reference->getTitle(); + $label = (string) Utils::getChild($reference, Text::class)?->getLiteral(); + $target = rawurldecode($reference->getUrl()); + + if (Utils::getContainer($reference) instanceof TableCell) { + $title = str_replace('|', '\\|', $title); + $label = str_replace('|', '\\|', $label); + $target = str_replace('|', '\\|', $target); + } + + $text = $title + ? Utils::getLink('[%s](%s %s)', $label, $target, $title, null, null) + : Utils::getLink('[%s](%s)', $label, $target, '', null, null); + + if ($reference instanceof Image) { + $text = "!{$text}"; + } + } elseif ($reference instanceof Reference) { + $text = ''; + } else { + // skipped + } + + if ($text !== null) { + $changes[] = [$location, $text ?: null]; + } + } + + // Return + return $changes; + } + + /** + * @return list<AbstractWebResource|Reference> + */ + protected function getReferences(DocumentNode $node): array { + $references = []; + + foreach ($node->iterator() as $child) { + if ($child instanceof AbstractWebResource && Utils::isReference($child)) { + $references[] = $child; + } elseif ($child instanceof Reference) { + $references[] = $child; + } else { + // empty + } + } + + return $references; + } +} diff --git a/packages/documentator/src/Markdown/Mutations/ReferencesInlineTest.php b/packages/documentator/src/Markdown/Mutations/ReferencesInlineTest.php new file mode 100644 index 000000000..bf9d8d481 --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/ReferencesInlineTest.php @@ -0,0 +1,79 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Editor; +use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Node\Block\Document as DocumentNode; +use PHPUnit\Framework\Attributes\CoversClass; +use ReflectionProperty; + +/** + * @internal + */ +#[CoversClass(ReferencesInline::class)] +final class ReferencesInlineTest extends TestCase { + public function testInvoke(): void { + $document = new Document( + <<<'MARKDOWN' + # Header + + Text text [link](https://example.com) text text [link][link] text + text text ![image][image] text text. + + ![image][image] + + [link]: https://example.com + [image]: https://example.com (image) + [table]: https://example.com (table | cell) + + # Special + + ## Inside Quote + + > ![image][link] + + ## Inside Table + + | Header | [Header][link] | + |-------------------------|-------------------------------| + | Cell [link][link] cell. | Cell `\|` \\| ![table][table] | + | Cell | Cell cell ![table][link]. | + MARKDOWN, + ); + $node = Cast::to(DocumentNode::class, (new ReflectionProperty($document, 'node'))->getValue($document)); + $lines = Data::get($node, Lines::class) ?? []; + $mutation = new ReferencesInline(); + $changes = $mutation($document, $node); + $actual = (string) (new Editor($lines))->mutate($changes); + + self::assertEquals( + <<<'MARKDOWN' + # Header + + Text text [link](https://example.com) text text [link](https://example.com) text + text text ![image](https://example.com "image") text text. + + ![image](https://example.com "image") + + # Special + + ## Inside Quote + + > ![image](https://example.com) + + ## Inside Table + + | Header | [Header](https://example.com) | + |-------------------------|-------------------------------| + | Cell [link](https://example.com) cell. | Cell `\|` \\| ![table](https://example.com "table \| cell") | + | Cell | Cell cell ![table](https://example.com). | + MARKDOWN, + $actual, + ); + } +} diff --git a/packages/documentator/src/Markdown/Mutations/ReferencesPrefix.php b/packages/documentator/src/Markdown/Mutations/ReferencesPrefix.php new file mode 100644 index 000000000..451aac9d2 --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/ReferencesPrefix.php @@ -0,0 +1,120 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Documentator\Markdown\Contracts\Mutation; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference\Block as Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; +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\Node\Block\Document as DocumentNode; +use League\CommonMark\Node\Inline\Text; +use Override; + +use function hash; +use function mb_strlen; +use function str_replace; +use function uniqid; + +/** + * Adds unique prefix for all references. + */ +class ReferencesPrefix implements Mutation { + public function __construct( + /** + * If the prefix is not specified, the hash of the document path will + * be used. If the document path is unknown, the random hash will be + * used. + */ + protected ?string $prefix = null, + ) { + // empty + } + + /** + * @inheritDoc + */ + #[Override] + public function __invoke(Document $document, DocumentNode $node): array { + $prefix = $this->prefix ?: hash('xxh3', $document->getPath() ?: uniqid($this::class)); // @phpstan-ignore disallowed.function + $changes = []; + $references = $this->getReferences($node); + + foreach ($references as $reference) { + // Location? + $location = Utils::getLocation($reference); + + if (!$location) { + continue; + } + + // Changes + $text = null; + + if ($reference instanceof Link || $reference instanceof Image) { + $label = (string) Utils::getChild($reference, Text::class)?->getLiteral(); + $target = Utils::getReference($reference)?->getLabel(); + $target = "{$prefix}-{$target}"; + + if (Utils::getContainer($reference) instanceof TableCell) { + $label = str_replace('|', '\\|', $label); + $target = str_replace('|', '\\|', $target); + } + + $text = Utils::getLink('[%s][%s]', $label, $target, '', null, null); + + if ($reference instanceof Image) { + $text = "!{$text}"; + } + } elseif ($reference instanceof Reference) { + $coordinate = null; + + foreach ($location as $c) { + $coordinate = $c; + break; + } + + if ($coordinate) { + $startLine = $coordinate->line; + $endLine = $startLine; + $offset = $coordinate->offset + 1; + $length = mb_strlen($reference->getLabel()); + $text = "{$prefix}-{$reference->getLabel()}"; + $location = new Locator($startLine, $endLine, $offset, $length); + } + } else { + // skipped + } + + if ($text !== null) { + $changes[] = [$location, $text]; + } + } + + // Return + return $changes; + } + + /** + * @return list<AbstractWebResource|Reference> + */ + protected function getReferences(DocumentNode $node): array { + $references = []; + + foreach ($node->iterator() as $child) { + if ($child instanceof AbstractWebResource && Utils::isReference($child)) { + $references[] = $child; + } elseif ($child instanceof Reference) { + $references[] = $child; + } else { + // empty + } + } + + return $references; + } +} diff --git a/packages/documentator/src/Markdown/Mutations/ReferencesPrefixTest.php b/packages/documentator/src/Markdown/Mutations/ReferencesPrefixTest.php new file mode 100644 index 000000000..f6d9d0629 --- /dev/null +++ b/packages/documentator/src/Markdown/Mutations/ReferencesPrefixTest.php @@ -0,0 +1,118 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Mutations; + +use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Lines; +use LastDragon_ru\LaraASP\Documentator\Markdown\Document; +use LastDragon_ru\LaraASP\Documentator\Markdown\Editor; +use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; +use League\CommonMark\Node\Block\Document as DocumentNode; +use PHPUnit\Framework\Attributes\CoversClass; +use ReflectionProperty; + +/** + * @internal + */ +#[CoversClass(ReferencesPrefix::class)] +final class ReferencesPrefixTest extends TestCase { + private const Markdown = <<<'MARKDOWN' + # Header + + Text text [link](https://example.com) text text [link][link] text + text text ![image][image] text text. + + ![image][image] + + [link]: https://example.com + [image]: https://example.com + + # Special + + ## Inside Quote + + > ![image][link] + + ## Inside Table + + | Header | [Header][link] | + |-------------------------|-------------------------------| + | Cell [link][link] cell. | Cell `\|` \\| ![table][image] | + | Cell | Cell cell [table][link]. | + MARKDOWN; + + public function testInvoke(): void { + $document = new Document(self::Markdown, 'path/to/file.md'); + $node = Cast::to(DocumentNode::class, (new ReflectionProperty($document, 'node'))->getValue($document)); + $lines = Data::get($node, Lines::class) ?? []; + $mutation = new ReferencesPrefix(); + $changes = $mutation($document, $node); + $actual = (string) (new Editor($lines))->mutate($changes); + + self::assertEquals( + <<<'MARKDOWN' + # Header + + Text text [link](https://example.com) text text [link][a282e9c32e7eee65-link] text + text text ![image][a282e9c32e7eee65-image] text text. + + ![image][a282e9c32e7eee65-image] + + [a282e9c32e7eee65-link]: https://example.com + [a282e9c32e7eee65-image]: https://example.com + + # Special + + ## Inside Quote + + > ![image][a282e9c32e7eee65-link] + + ## Inside Table + + | Header | [Header][a282e9c32e7eee65-link] | + |-------------------------|-------------------------------| + | Cell [link][a282e9c32e7eee65-link] cell. | Cell `\|` \\| ![table][a282e9c32e7eee65-image] | + | Cell | Cell cell [table][a282e9c32e7eee65-link]. | + MARKDOWN, + $actual, + ); + } + + public function testInvokeExplicit(): void { + $document = new Document(self::Markdown, 'path/to/file.md'); + $node = Cast::to(DocumentNode::class, (new ReflectionProperty($document, 'node'))->getValue($document)); + $lines = Data::get($node, Lines::class) ?? []; + $mutation = new ReferencesPrefix('prefix'); + $changes = $mutation($document, $node); + $actual = (string) (new Editor($lines))->mutate($changes); + + self::assertEquals( + <<<'MARKDOWN' + # Header + + Text text [link](https://example.com) text text [link][prefix-link] text + text text ![image][prefix-image] text text. + + ![image][prefix-image] + + [prefix-link]: https://example.com + [prefix-image]: https://example.com + + # Special + + ## Inside Quote + + > ![image][prefix-link] + + ## Inside Table + + | Header | [Header][prefix-link] | + |-------------------------|-------------------------------| + | Cell [link][prefix-link] cell. | Cell `\|` \\| ![table][prefix-image] | + | Cell | Cell cell [table][prefix-link]. | + MARKDOWN, + $actual, + ); + } +} diff --git a/packages/documentator/src/Markdown/Nodes/Aware.php b/packages/documentator/src/Markdown/Nodes/Aware.php new file mode 100644 index 000000000..658757383 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/Aware.php @@ -0,0 +1,37 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes; + +use League\CommonMark\Environment\EnvironmentAwareInterface; +use League\CommonMark\Environment\EnvironmentInterface; +use League\Config\ConfigurationAwareInterface; +use League\Config\ConfigurationInterface; +use Override; + +/** + * @internal + * + * @phpstan-require-implements EnvironmentAwareInterface + * @phpstan-require-implements ConfigurationAwareInterface + */ +trait Aware { + #[Override] + public function setEnvironment(EnvironmentInterface $environment): void { + $object = $this->getObject(); + + if ($object instanceof EnvironmentAwareInterface) { + $object->setEnvironment($environment); + } + } + + #[Override] + public function setConfiguration(ConfigurationInterface $configuration): void { + $object = $this->getObject(); + + if ($object instanceof ConfigurationAwareInterface) { + $object->setConfiguration($configuration); + } + } + + abstract protected function getObject(): object; +} diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Listener.php b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php index 98a922966..b0444191e 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Listener.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Listener.php @@ -3,13 +3,16 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; use LastDragon_ru\LaraASP\Core\Utils\Cast; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\BlockPadding; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Length; 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; +use League\CommonMark\Extension\Footnote\Node\Footnote; use League\CommonMark\Extension\Table\Table; use League\CommonMark\Extension\Table\TableCell; use League\CommonMark\Extension\Table\TableRow; @@ -28,6 +31,12 @@ use function preg_split; /** + * Fix/Detect location/padding. + * + * Out the box only start/end line know. But not for all notes, for example, + * `Table`'s nodes don't have this information. Another important thing - + * padding of the block node. + * * @internal */ class Listener implements EnvironmentAwareInterface { @@ -38,8 +47,7 @@ public function __construct() { } public function __invoke(DocumentParsedEvent $event): void { - // Fix `Table` nodes (they don't have a start/end line) and detect padding. - // (we are expecting that iteration happens in the document order) + // Fix/Detect $document = $event->getDocument(); foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { @@ -47,6 +55,8 @@ public function __invoke(DocumentParsedEvent $event): void { $this->fixTableSection($document, $node); } elseif ($node instanceof TableRow) { $this->fixTableRow($document, $node); + } elseif ($node instanceof Footnote) { + $this->fixFootnote($document, $node); } else { // empty } @@ -127,9 +137,13 @@ private function fixTableRow(Document $document, TableRow $row): void { // Yep $cells = preg_split('/(?<!\\\\)[|]/u', mb_substr($text, $padding)) ?: []; // `|` must be always escaped - $cells = array_slice($cells, 1, -1); // First and Last characters are `|`, skip them + $cells = array_slice( + $cells, + 1, + -1, + ); // First and Last characters are `|`, skip them $index = 0; - $offset = $padding; + $offset = 1; $children = $this->toArray($row->children()); if (count($children) !== count($cells)) { @@ -141,20 +155,54 @@ private function fixTableRow(Document $document, TableRow $row): void { $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)); + Data::set($cell, new BlockPadding($padding)); + Data::set($cell, new Padding($trimmed)); + Data::set($cell, new Offset($offset)); + Data::set($cell, new Length($length)); - $offset += $unused; + $offset += $length + 1; $index += 1; } } + private function fixFootnote(Document $document, Footnote $footnote): void { + // Possible? + $start = $footnote->getStartLine(); + $end = $footnote->getEndLine(); + + if ($start === null || $end === null) { + return; + } + + // Initial + $initial = Utils::getPadding($footnote, $start, '[^'); + + if ($initial === null) { + return; + } + + if ($start === $end) { + return; + } + + // Internal + $padding = null; + $index = $start + 1; + + do { + $line = (string) Utils::getLine($document, $index++); + $line = mb_substr($line, $initial); + $trimmed = ltrim($line); + $padding = mb_strlen($line) - mb_strlen($trimmed); + } while ($index < $end && $trimmed === ''); + + Data::set($footnote, new Padding($padding)); + } + /** * @template T * diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php index 8dd84d51b..177f86182 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/Parser.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/Parser.php @@ -2,26 +2,27 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Locator; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\BlockPadding; 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\Nodes\Aware; use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; use LastDragon_ru\LaraASP\Documentator\Utils\Text; use League\CommonMark\Delimiter\DelimiterInterface; use League\CommonMark\Delimiter\DelimiterStack; use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\EnvironmentAwareInterface; -use League\CommonMark\Environment\EnvironmentInterface; +use League\CommonMark\Extension\CommonMark\Parser\Inline\CloseBracketParser; use League\CommonMark\Extension\Table\TableCell; use League\CommonMark\Node\Node; use League\CommonMark\Parser\Inline\InlineParserInterface; use League\CommonMark\Parser\Inline\InlineParserMatch; use League\CommonMark\Parser\InlineParserContext; use League\Config\ConfigurationAwareInterface; -use League\Config\ConfigurationInterface; use Override; use ReflectionProperty; use WeakMap; @@ -45,6 +46,8 @@ * @see Environment */ class Parser implements InlineParserInterface, EnvironmentAwareInterface, ConfigurationAwareInterface { + use Aware; + /** * @var WeakMap<Node, Coordinate> */ @@ -56,6 +59,11 @@ public function __construct( $this->incomplete = new WeakMap(); } + #[Override] + protected function getObject(): object { + return $this->parser; + } + #[Override] public function getMatchDefinition(): InlineParserMatch { return $this->parser->getMatchDefinition(); @@ -66,9 +74,13 @@ 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 + $offset = $cursor->getPosition(); + + if ($this->parser instanceof CloseBracketParser) { + $offset = $offset + - $this->getDelimiterStackLength($inlineContext->getDelimiterStack()) // delimiters length + - mb_strlen($cursor->getPreviousText()); // text after delimiter + } // Parse $parsed = $this->parser->parse($inlineContext); @@ -141,12 +153,19 @@ public function finalize(): void { } // 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) { + $blockStartLine = $container->getStartLine(); + $blockEndLine = $container->getEndLine(); + $blockPadding = Data::get($container, BlockPadding::class); + $cellPadding = Data::get($container, Padding::class); + $offset = Data::get($container, Offset::class); + + if ( + $blockStartLine === null + || $blockEndLine === null + || $blockPadding === null + || $cellPadding === null + || $offset === null + ) { continue; } @@ -155,11 +174,11 @@ public function finalize(): void { $node, new Location( new Locator( - $startLine, - $endLine, + $blockStartLine, + $blockEndLine, $coordinate->offset + $offset, $coordinate->length, - $padding, + $blockPadding + $cellPadding, ), ), ); @@ -169,20 +188,6 @@ public function finalize(): void { $this->incomplete = new WeakMap(); } - #[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 = $delimiter instanceof DelimiterInterface diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php index 6182ddd83..46a29f61a 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest.php @@ -5,9 +5,22 @@ use LastDragon_ru\LaraASP\Documentator\Markdown\Extension; 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\Markdown\Nodes\RendererWrapper; 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\Extension\CommonMark\Renderer\Inline\ImageRenderer; +use League\CommonMark\Extension\CommonMark\Renderer\Inline\LinkRenderer; +use League\CommonMark\Extension\Footnote\Node\Footnote; +use League\CommonMark\Extension\Footnote\Node\FootnoteRef; +use League\CommonMark\Extension\Footnote\Renderer\FootnoteRefRenderer; +use League\CommonMark\Extension\Footnote\Renderer\FootnoteRenderer; +use League\CommonMark\Extension\Table\TableCell; +use League\CommonMark\Extension\Table\TableCellRenderer; +use League\CommonMark\Extension\Table\TableRow; +use League\CommonMark\Extension\Table\TableRowRenderer; +use League\CommonMark\Extension\Table\TableSection; +use League\CommonMark\Extension\Table\TableSectionRenderer; use League\CommonMark\GithubFlavoredMarkdownConverter; use League\CommonMark\Parser\MarkdownParser; use League\CommonMark\Xml\XmlRenderer; @@ -17,15 +30,19 @@ * @internal */ #[CoversClass(Parser::class)] -#[CoversClass(Renderer::class)] final class ParserTest extends TestCase { public function testParse(): void { $converter = new GithubFlavoredMarkdownConverter(); $environment = $converter->getEnvironment() ->addExtension(new Extension()) - ->addRenderer(Link::class, new Renderer()) - ->addRenderer(Image::class, new Renderer()) - ->addRenderer(ReferenceNode::class, new ReferenceRenderer()); + ->addRenderer(Link::class, new RendererWrapper(new LinkRenderer())) + ->addRenderer(Image::class, new RendererWrapper(new ImageRenderer())) + ->addRenderer(Footnote::class, new RendererWrapper(new FootnoteRenderer())) + ->addRenderer(FootnoteRef::class, new RendererWrapper(new FootnoteRefRenderer())) + ->addRenderer(TableSection::class, new RendererWrapper(new TableSectionRenderer())) + ->addRenderer(TableRow::class, new RendererWrapper(new TableRowRenderer())) + ->addRenderer(TableCell::class, new RendererWrapper(new TableCellRenderer())) + ->addRenderer(ReferenceNode::class, new RendererWrapper(new ReferenceRenderer())); $parser = new MarkdownParser($environment); $document = $parser->parse(self::getTestData()->content('~document.md')); diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md index a79ab55e6..b46e3a3c0 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~document.md @@ -4,10 +4,10 @@ Text text _**[link](https://example.com/)**_. -Text text [link](https://example.com/) text [link](https://example.com/ "title") text +Text text [link](https://example.com/)[^1] text [link](https://example.com/ "title") text[^1] 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. +text text text text text text text text text text[^2] text text text text text text text +text text _[link](https://example.com/)_[^note] text. [link]: https://example.com/ "reference" @@ -53,3 +53,20 @@ text text _![image](https://example.com/)_ text. | Header | Header | |--------------------------------|--------| | ![image](https://example.com/) | Cell | + +# Footnotes + +[^1]: Footnote text text text text + +[^note]: Footnote text text text text text [link](https://example.com/)[^1] text + text text text [link](https://example.com/) text text text. + +> Text text[^quote] +> +> [^quote]: Footnote text text text text text [link](https://example.com/) +> text text text text text [link](https://example.com/). + +[^unused]: Text text text text text text text text text[^unused] + + Text text text text text text text text text text text text + text text text text text text text text text text text text. diff --git a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml index 4c4fd4d98..4d793ac98 100644 --- a/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Locator/ParserTest~expected.xml @@ -8,7 +8,7 @@ <text><![CDATA[Text text ]]></text> <emph> <strong> - <link location="[{5,13,28}]" title="" url="https://example.com/"> + <link location="[{5,13,28}]" title="" destination="https://example.com/"> <text>link</text> </link> </strong> @@ -17,36 +17,41 @@ </paragraph> <paragraph> <text><![CDATA[Text text ]]></text> - <link location="[{7,10,28}]" title="" url="https://example.com/"> + <link location="[{7,10,28}]" title="" destination="https://example.com/"> <text>link</text> </link> + <footnote_ref location="[{7,38,4}]" reference="1"/> <text><![CDATA[ text ]]></text> - <link location="[{7,44,36}]" title="title" url="https://example.com/"> + <link location="[{7,48,36}]" title="title" destination="https://example.com/"> <text>link</text> </link> <text><![CDATA[ text]]></text> + <footnote_ref location="[{7,89,4}]" reference="1__2"/> <softbreak/> <text><![CDATA[text text text text ]]></text> - <link location="[{8,20,12}]" title="reference" url="https://example.com/"> + <link location="[{8,20,12}]" title="reference" destination="https://example.com/"> <text>link</text> </link> <text><![CDATA[ text text ]]></text> - <link location="[{8,43,28}]" title="" url="https://example.com/"> + <link location="[{8,43,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text><![CDATA[ text text text text text text]]></text> <softbreak/> - <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 text text text text text</text> + <text>[^2]</text> + <text><![CDATA[ text text text text text text text]]></text> <softbreak/> <text><![CDATA[text text ]]></text> <emph> - <link location="[{10,11,28}]" title="" url="https://example.com/"> + <link location="[{10,11,28}]" title="" destination="https://example.com/"> <text>link</text> </link> </emph> + <footnote_ref location="[{10,40,7}]" reference="note"/> <text><![CDATA[ text.]]></text> </paragraph> - <reference destination="https://example.com/" label="link" location="[{12,0,null}]" title="reference"/> + <reference destination="https://example.com/" label="link" location="[{12,0,null}]" blockPadding="0" title="reference"/> <heading level="1"> <text>Lists</text> </heading> @@ -54,7 +59,7 @@ <item> <paragraph> <text><![CDATA[List list ]]></text> - <link location="[{16,12,28}]" title="" url="https://example.com/"> + <link location="[{16,12,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text>.</text> @@ -63,7 +68,7 @@ <item> <paragraph> <text><![CDATA[List list ]]></text> - <link location="[{17,14,28}]" title="" url="https://example.com/"> + <link location="[{17,14,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text>.</text> @@ -72,7 +77,7 @@ <item> <paragraph> <text><![CDATA[List list ]]></text> - <link location="[{18,14,34}]" title="\|" url="https://example.com/"> + <link location="[{18,14,34}]" title="\|" destination="https://example.com/"> <text>link</text> </link> <text>.</text> @@ -87,7 +92,7 @@ <block_quote> <paragraph> <text><![CDATA[Quote quote ]]></text> - <link location="[{22,14,28}]" title="" url="https://example.com/"> + <link location="[{22,14,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text>.</text> @@ -96,7 +101,7 @@ <text>Quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote quote</text> <softbreak/> <text><![CDATA[quote quote ]]></text> - <link location="[{25,14,28}]" title="" url="https://example.com/"> + <link location="[{25,14,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text><![CDATA[ quote.]]></text> @@ -106,7 +111,7 @@ <block_quote> <paragraph> <text><![CDATA[Quote quote ]]></text> - <link location="[{27,16,28}]" title="" url="https://example.com/"> + <link location="[{27,16,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text>.</text> @@ -117,44 +122,44 @@ <text>Tables</text> </heading> <table> - <table_section type="head"> - <table_row> - <table_cell type="header"> + <table_section type="head" location="[{31,0,null}]" blockPadding="0"> + <table_row location="[{31,0,null}]" blockPadding="0"> + <table_cell type="header" location="[{31,1,25}]" blockPadding="0" padding="1"> <text>Header</text> </table_cell> - <table_cell type="header"> + <table_cell type="header" location="[{31,27,75}]" blockPadding="0" padding="2"> <text>Header (</text> - <link location="[{31,37,28}]" title="" url="https://example.com/"> + <link location="[{31,37,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text>)</text> </table_cell> </table_row> </table_section> - <table_section type="body"> - <table_row> - <table_cell type="data"> + <table_section type="body" location="[{33,0,null},{34,0,null}]" blockPadding="0"> + <table_row location="[{33,0,null}]" blockPadding="0"> + <table_cell type="data" location="[{33,1,25}]" blockPadding="0" padding="1"> <text><![CDATA[Cell ]]></text> - <link location="[{33,7,12}]" title="reference" url="https://example.com/"> + <link location="[{33,7,12}]" title="reference" destination="https://example.com/"> <text>link</text> </link> <text><![CDATA[ cell.]]></text> </table_cell> - <table_cell type="data"> + <table_cell type="data" location="[{33,27,75}]" blockPadding="0" padding="1"> <text>Cell</text> </table_cell> </table_row> - <table_row> - <table_cell type="data"> + <table_row location="[{34,0,null}]" blockPadding="0"> + <table_cell type="data" location="[{34,1,25}]" blockPadding="0" padding="1"> <text>Cell</text> </table_cell> - <table_cell type="data"> + <table_cell type="data" location="[{34,27,75}]" blockPadding="0" padding="1"> <text><![CDATA[Cell cell ]]></text> - <link location="[{34,38,28}]" title="" url="https://example.com/"> + <link location="[{34,38,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text><![CDATA[ cell ]]></text> - <link location="[{34,72,28}]" title="" url="https://example.com/"> + <link location="[{34,72,28}]" title="" destination="https://example.com/"> <text>link</text> </link> <text>.</text> @@ -164,27 +169,27 @@ </table> <block_quote> <table> - <table_section type="head"> - <table_row> - <table_cell type="header"> + <table_section type="head" location="[{36,2,null}]" blockPadding="2"> + <table_row location="[{36,2,null}]" blockPadding="2"> + <table_cell type="header" location="[{36,3,50}]" blockPadding="2" padding="1"> <text>Header</text> </table_cell> - <table_cell type="header"> + <table_cell type="header" location="[{36,54,8}]" blockPadding="2" padding="1"> <text>Header</text> </table_cell> </table_row> </table_section> - <table_section type="body"> - <table_row> - <table_cell type="data"> + <table_section type="body" location="[{38,2,null}]" blockPadding="2"> + <table_row location="[{38,2,null}]" blockPadding="2"> + <table_cell type="data" location="[{38,3,50}]" blockPadding="2" padding="1"> <text><![CDATA[Cell ]]></text> <code>|</code> <text><![CDATA[ | ]]></text> - <link location="[{38,18,34}]" title="|" url="https://example.com/"> + <link location="[{38,18,34}]" title="|" destination="https://example.com/"> <text>link</text> </link> </table_cell> - <table_cell type="data"> + <table_cell type="data" location="[{38,54,8}]" blockPadding="2" padding="1"> <text>Cell</text> </table_cell> </table_row> @@ -196,20 +201,20 @@ </heading> <paragraph> <text><![CDATA[Text text ]]></text> - <image location="[{42,10,30}]" title="" url="https://example.com/"> + <image location="[{42,10,30}]" title="" destination="https://example.com/"> <text>image</text> </image> <text><![CDATA[ text ]]></text> - <image location="[{42,46,38}]" title="title" url="https://example.com/"> + <image location="[{42,46,38}]" title="title" destination="https://example.com/"> <text>image</text> </image> <softbreak/> <text><![CDATA[text text ]]></text> - <image location="[{43,10,14}]" title="reference" url="https://example.com/"> + <image location="[{43,10,14}]" title="reference" destination="https://example.com/"> <text>image</text> </image> <text><![CDATA[ text text ]]></text> - <image location="[{43,35,30}]" title="" url="https://example.com/"> + <image location="[{43,35,30}]" title="" destination="https://example.com/"> <text>image</text> </image> <text><![CDATA[ text text text]]></text> @@ -218,51 +223,114 @@ <softbreak/> <text><![CDATA[text text ]]></text> <emph> - <image location="[{45,11,30}]" title="" url="https://example.com/"> + <image location="[{45,11,30}]" title="" destination="https://example.com/"> <text>image</text> </image> </emph> <text><![CDATA[ text.]]></text> </paragraph> <paragraph> - <image location="[{47,0,30}]" title="" url="https://example.com/"> + <image location="[{47,0,30}]" title="" destination="https://example.com/"> <text>image</text> </image> </paragraph> <paragraph> - <image location="[{49,0,14}]" title="reference" url="https://example.com/"> + <image location="[{49,0,14}]" title="reference" destination="https://example.com/"> <text>image</text> </image> </paragraph> <block_quote> <paragraph> - <image location="[{51,2,30}]" title="" url="https://example.com/"> + <image location="[{51,2,30}]" title="" destination="https://example.com/"> <text>image</text> </image> </paragraph> </block_quote> <table> - <table_section type="head"> - <table_row> - <table_cell type="header"> + <table_section type="head" location="[{53,0,null}]" blockPadding="0"> + <table_row location="[{53,0,null}]" blockPadding="0"> + <table_cell type="header" location="[{53,1,32}]" blockPadding="0" padding="1"> <text>Header</text> </table_cell> - <table_cell type="header"> + <table_cell type="header" location="[{53,34,8}]" blockPadding="0" padding="1"> <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/"> + <table_section type="body" location="[{55,0,null}]" blockPadding="0"> + <table_row location="[{55,0,null}]" blockPadding="0"> + <table_cell type="data" location="[{55,1,32}]" blockPadding="0" padding="1"> + <image location="[{55,2,30}]" title="" destination="https://example.com/"> <text>image</text> </image> </table_cell> - <table_cell type="data"> + <table_cell type="data" location="[{55,34,8}]" blockPadding="0" padding="1"> <text>Cell</text> </table_cell> </table_row> </table_section> </table> + <heading level="1"> + <text>Footnotes</text> + </heading> + <block_quote> + <paragraph> + <text>Text text</text> + <footnote_ref location="[{64,11,8}]" reference="quote"/> + </paragraph> + </block_quote> + <footnote_container> + <footnote reference="1" location="[{59,0,null},{60,0,null}]" blockPadding="0" padding="0"> + <paragraph> + <text>Footnote text text text text</text> + <footnote_backref reference="1"/> + <footnote_backref reference="1__2"/> + <footnote_backref reference="1__3"/> + </paragraph> + </footnote> + <footnote reference="note" location="[{61,0,null},{62,0,null},{63,0,null}]" blockPadding="0" padding="4"> + <paragraph> + <text><![CDATA[Footnote text text text text text ]]></text> + <link destination="https://example.com/" location="[{61,43,28}]" title=""> + <text>link</text> + </link> + <footnote_ref location="[{61,71,4}]" reference="1__3"/> + <text><![CDATA[ text]]></text> + <softbreak/> + <text><![CDATA[text text text ]]></text> + <link destination="https://example.com/" location="[{62,19,28}]" title=""> + <text>link</text> + </link> + <text><![CDATA[ text text text.]]></text> + <footnote_backref reference="note"/> + </paragraph> + </footnote> + <footnote reference="quote" location="[{66,2,null},{67,2,null}]" blockPadding="2" padding="4"> + <paragraph> + <text><![CDATA[Footnote text text text text text ]]></text> + <link destination="https://example.com/" location="[{66,46,28}]" title=""> + <text>link</text> + </link> + <softbreak/> + <text><![CDATA[text text text text text ]]></text> + <link destination="https://example.com/" location="[{67,31,28}]" title=""> + <text>link</text> + </link> + <text>.</text> + <footnote_backref reference="quote"/> + </paragraph> + </footnote> + <footnote location="[{69,0,null},{70,0,null},{71,0,null},{72,0,null}]" blockPadding="0" padding="4" reference="unused"> + <paragraph> + <text>Text text text text text text text text text</text> + <footnote_ref location="[{69,55,9}]" reference="unused"/> + </paragraph> + <paragraph> + <text>Text text text text text text text text text text text text</text> + <softbreak/> + <text>text text text text text text text text text text text text.</text> + <footnote_backref reference="unused"/> + </paragraph> + </footnote> + </footnote_container> </document> diff --git a/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php b/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php deleted file mode 100644 index 02a5c2a75..000000000 --- a/packages/documentator/src/Markdown/Nodes/Locator/Renderer.php +++ /dev/null @@ -1,37 +0,0 @@ -<?php declare(strict_types = 1); - -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; - -use function assert; - -/** - * @internal - */ -class Renderer extends XmlRenderer { - #[Override] - public function getXmlTagName(Node $node): string { - assert($node instanceof Link || $node instanceof Image); - - return $node instanceof Link ? 'link' : 'image'; - } - - /** - * @inheritDoc - */ - #[Override] - public function getXmlAttributes(Node $node): array { - assert($node instanceof Link || $node instanceof Image); - - return [ - 'url' => $this->escape($node->getUrl()), - 'title' => $this->escape($node->getTitle()), - 'location' => $this->location($node), - ]; - } -} diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php index 82f3a555a..e211c004f 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserContinue.php @@ -2,9 +2,9 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\BlockPadding; 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; use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Parser\Block\BlockContinue; @@ -82,7 +82,7 @@ public function closeBlock(): void { } // Data - Data::set($this->block, new Padding($this->padding)); + Data::set($this->block, new BlockPadding($this->padding)); $start = $this->block->getStartLine(); $end = $this->block->getEndLine(); diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php index 3f0942e0c..15ba2dd46 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest.php @@ -3,6 +3,7 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; use LastDragon_ru\LaraASP\Documentator\Markdown\Extension; +use LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\RendererWrapper; use LastDragon_ru\LaraASP\Documentator\Testing\Package\TestCase; use League\CommonMark\GithubFlavoredMarkdownConverter; use League\CommonMark\Parser\MarkdownParser; @@ -21,7 +22,7 @@ public function testParse(): void { $converter = new GithubFlavoredMarkdownConverter(); $environment = $converter->getEnvironment() ->addExtension(new Extension()) - ->addRenderer(Block::class, new Renderer()); + ->addRenderer(Block::class, new RendererWrapper(new Renderer())); $parser = new MarkdownParser($environment); $document = $parser->parse(self::getTestData()->content('~document.md')); diff --git a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml index 03a28e94d..229363716 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml +++ b/packages/documentator/src/Markdown/Nodes/Reference/ParserTest~expected.xml @@ -9,36 +9,42 @@ destination="https://example.com/" title="" location="[{5,0,null}]" + blockPadding="0" /> <reference label="simple:b" destination="https://example.com/" title="example.com" location="[{7,0,null}]" + blockPadding="0" /> <reference label="simple:c" destination="https://example.com/" title="" location="[{8,0,null}]" + blockPadding="0" /> <reference label="simple:d" destination="file/b" title="title" location="[{9,0,null}]" + blockPadding="0" /> <reference label="simple:e" destination="file/b" title="" location="[{11,0,null}]" + blockPadding="0" /> <reference title="" label="simple:e" destination="file/b" location="[{12,0,null}]" + blockPadding="0" /> <heading level="1"> <text>Multiline</text> @@ -48,12 +54,14 @@ 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}]" + blockPadding="0" /> <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}]" + blockPadding="0" /> <heading level="1"> <text>Inside Quote</text> @@ -64,12 +72,14 @@ destination="https://example.com/" title="example.com" location="[{30,2,null}]" + blockPadding="2" /> <reference label="quote:b" destination="https://example.com/" title="" location="[{32,2,null},{33,2,null}]" + blockPadding="2" /> </block_quote> <block_quote> @@ -79,12 +89,14 @@ destination="https://example.com/" title="example.com" location="[{35,4,null}]" + blockPadding="4" /> <reference label="quote:d" destination="https://example.com/" title="example.com" location="[{37,4,null},{38,4,null},{39,4,null}]" + blockPadding="4" /> </block_quote> </block_quote> diff --git a/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php index 7f356eb05..a369e6012 100644 --- a/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php +++ b/packages/documentator/src/Markdown/Nodes/Reference/Renderer.php @@ -2,16 +2,24 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes\Reference; -use LastDragon_ru\LaraASP\Documentator\Markdown\XmlRenderer; use League\CommonMark\Node\Node; +use League\CommonMark\Renderer\ChildNodeRendererInterface; +use League\CommonMark\Renderer\NodeRendererInterface; +use League\CommonMark\Xml\XmlNodeRendererInterface; use Override; +use Stringable; use function assert; /** * @internal */ -class Renderer extends XmlRenderer { +class Renderer implements NodeRendererInterface, XmlNodeRendererInterface { + #[Override] + public function render(Node $node, ChildNodeRendererInterface $childRenderer): Stringable|string|null { + return null; + } + #[Override] public function getXmlTagName(Node $node): string { assert($node instanceof Block); @@ -27,10 +35,9 @@ public function getXmlAttributes(Node $node): array { assert($node instanceof Block); return [ - 'label' => $this->escape($node->getLabel()), - 'destination' => $this->escape($node->getDestination()), - 'title' => $this->escape($node->getTitle()), - 'location' => $this->location($node), + 'label' => $node->getLabel(), + 'destination' => $node->getDestination(), + 'title' => $node->getTitle(), ]; } } diff --git a/packages/documentator/src/Markdown/Nodes/RendererWrapper.php b/packages/documentator/src/Markdown/Nodes/RendererWrapper.php new file mode 100644 index 000000000..76d6e19c3 --- /dev/null +++ b/packages/documentator/src/Markdown/Nodes/RendererWrapper.php @@ -0,0 +1,110 @@ +<?php declare(strict_types = 1); + +namespace LastDragon_ru\LaraASP\Documentator\Markdown\Nodes; + +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\BlockPadding; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Padding; +use LastDragon_ru\LaraASP\Documentator\Markdown\Utils; +use League\CommonMark\Environment\EnvironmentAwareInterface; +use League\CommonMark\Node\Block\AbstractBlock; +use League\CommonMark\Node\Node; +use League\CommonMark\Renderer\ChildNodeRendererInterface; +use League\CommonMark\Renderer\NodeRendererInterface; +use League\CommonMark\Xml\XmlNodeRendererInterface; +use League\Config\ConfigurationAwareInterface; +use Override; +use Stringable; + +use function array_filter; +use function array_merge; +use function array_walk_recursive; +use function implode; +use function is_string; +use function preg_replace; + +/** + * @internal + */ +readonly class RendererWrapper implements + NodeRendererInterface, + XmlNodeRendererInterface, + EnvironmentAwareInterface, + ConfigurationAwareInterface { + use Aware; + + public function __construct( + protected NodeRendererInterface|XmlNodeRendererInterface $renderer, + ) { + // empty + } + + #[Override] + protected function getObject(): object { + return $this->renderer; + } + + #[Override] + public function render(Node $node, ChildNodeRendererInterface $childRenderer): Stringable|string|null { + return $this->renderer instanceof NodeRendererInterface + ? $this->renderer->render($node, $childRenderer) + : ''; + } + + #[Override] + public function getXmlTagName(Node $node): string { + return $this->renderer instanceof XmlNodeRendererInterface + ? $this->renderer->getXmlTagName($node) + : ''; + } + + /** + * @inheritDoc + */ + #[Override] + public function getXmlAttributes(Node $node): array { + $additional = $this->getXmlAdditionalAttributes($node); + $attributes = $this->renderer instanceof XmlNodeRendererInterface + ? $this->renderer->getXmlAttributes($node) + : []; + $attributes = array_merge($attributes, $additional); + + array_walk_recursive($attributes, function (mixed &$value): void { + if (is_string($value)) { + $value = $this->escape($value); + } + }); + + return $attributes; + } + + protected function escape(string $string): string { + return preg_replace('/\R/u', '\\n', $string) ?? $string; + } + + protected function location(Node $node): ?string { + $lines = []; + $location = Utils::getLocation($node) ?? []; + + foreach ($location as $line) { + $lines[] = '{'.implode(',', [$line->line, $line->offset, $line->length ?? 'null']).'}'; + } + + return $lines ? '['.implode(',', $lines).']' : null; + } + + /** + * @return array<string, scalar> + */ + private function getXmlAdditionalAttributes(Node $node): array { + $attributes = []; + $attributes['location'] = $this->location($node); + $attributes['padding'] = Data::get($node, Padding::class); + + if ($node instanceof AbstractBlock) { + $attributes['blockPadding'] = Data::get($node, BlockPadding::class); + } + + return array_filter($attributes, static fn ($v) => $v !== null); + } +} diff --git a/packages/documentator/src/Markdown/Utils.php b/packages/documentator/src/Markdown/Utils.php index 97bcc91a8..e2f0556f4 100644 --- a/packages/documentator/src/Markdown/Utils.php +++ b/packages/documentator/src/Markdown/Utils.php @@ -2,12 +2,20 @@ namespace LastDragon_ru\LaraASP\Documentator\Markdown; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\BlockPadding; use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Data; +use LastDragon_ru\LaraASP\Documentator\Markdown\Data\Length; 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\Location as LocationContract; +use LastDragon_ru\LaraASP\Documentator\Markdown\Location\Locator; +use League\CommonMark\Extension\CommonMark\Node\Inline\AbstractWebResource; use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Node\Block\Document; use League\CommonMark\Node\Node; +use League\CommonMark\Reference\ReferenceInterface; use League\CommonMark\Util\UrlEncoder; use function mb_strpos; @@ -60,8 +68,8 @@ public static function getPosition(Node $node): int { } /** - * Detect block padding. We are expecting that all lines inside the block - * have the same padding. + * Detect block padding. We are expecting that all lines except first inside + * the block have the same padding. */ public static function getPadding(Node $node, ?int $line, ?string $start): ?int { // Container? @@ -72,7 +80,10 @@ public static function getPadding(Node $node, ?int $line, ?string $start): ?int } // Known? - $padding = Data::get($container, Padding::class); + $type = $line === null || $line === $container->getStartLine() + ? BlockPadding::class + : Padding::class; + $padding = Data::get($container, $type); if ($padding !== null) { return $padding; @@ -98,7 +109,7 @@ public static function getPadding(Node $node, ?int $line, ?string $start): ?int } // Cache - Data::set($container, new Padding($padding)); + Data::set($container, new $type($padding)); // Return return $padding; @@ -111,6 +122,39 @@ public static function getLine(Document $document, int $line): ?string { return $line; } + public static function getLocation(Node $node): ?LocationContract { + $location = Data::get($node, Location::class); + + if ($location === null && $node instanceof AbstractBlock) { + $start = $node->getStartLine(); + $end = $node->getEndLine(); + $offset = Data::get($node, Offset::class) ?? 0; + $length = Data::get($node, Length::class); + $padding = self::getPadding($node, null, null); + + if ($padding === null && $node->parent() instanceof Document) { + $padding = 0; + } + + if ($start !== null && $end !== null && $padding !== null) { + $location = new Locator($start, $end, $offset, $length, $padding); + } + } + + return $location; + } + + public static function isReference(AbstractWebResource $node): bool { + return self::getReference($node) !== null; + } + + public static function getReference(AbstractWebResource $node): ?ReferenceInterface { + $reference = $node->data->get('reference', null); + $reference = $reference instanceof ReferenceInterface ? $reference : null; + + return $reference; + } + public static function getLink( string $format, string $label, diff --git a/packages/documentator/src/Markdown/XmlRenderer.php b/packages/documentator/src/Markdown/XmlRenderer.php deleted file mode 100644 index 85b553dde..000000000 --- a/packages/documentator/src/Markdown/XmlRenderer.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php declare(strict_types = 1); - -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; -use League\CommonMark\Renderer\NodeRendererInterface; -use League\CommonMark\Xml\XmlNodeRendererInterface; -use Override; - -use function implode; -use function preg_replace; - -/** - * @internal - */ -abstract class XmlRenderer implements NodeRendererInterface, XmlNodeRendererInterface { - #[Override] - public function render(Node $node, ChildNodeRendererInterface $childRenderer): string { - return ''; - } - - protected function escape(?string $string): string { - return preg_replace('/\R/u', '\\n', $string ?? '') ?? $string ?? ''; - } - - protected function location(Node $node): string { - $lines = []; - $location = Data::get($node, Location::class) ?? []; - - foreach ($location as $line) { - $lines[] = '{'.implode(',', [$line->line, $line->offset, $line->length ?? 'null']).'}'; - } - - return '['.implode(',', $lines).']'; - } -}