From 2fb493d58d579e61e78e1a624dc44b6a5329dfa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 09:04:36 +0000 Subject: [PATCH 1/8] Add Parsedown support with optional dependency Add ParsedownExtraAdapter, ParsedownFilter and ParsedownExtension to support markdown parsing via erusev/parsedown-extra package. The dependency is optional (suggested in composer.json). --- composer.json | 3 +- src/DI/ParsedownExtension.php | 58 +++++++++++++++++++++++++++ src/Filters/ParsedownExtraAdapter.php | 43 ++++++++++++++++++++ src/Filters/ParsedownFilter.php | 28 +++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/DI/ParsedownExtension.php create mode 100644 src/Filters/ParsedownExtraAdapter.php create mode 100644 src/Filters/ParsedownFilter.php diff --git a/composer.json b/composer.json index 483b9f0..08b94de 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "contributte/phpstan": "^0.1" }, "suggest": { - "nette/di": "to use VersionExtension[CompilerExtension]" + "nette/di": "to use VersionExtension[CompilerExtension]", + "erusev/parsedown-extra": "to use ParsedownExtension[CompilerExtension]" }, "autoload": { "psr-4": { diff --git a/src/DI/ParsedownExtension.php b/src/DI/ParsedownExtension.php new file mode 100644 index 0000000..7dc4670 --- /dev/null +++ b/src/DI/ParsedownExtension.php @@ -0,0 +1,58 @@ + Expect::string('parsedown'), + ]); + } + + public function loadConfiguration(): void + { + if (!class_exists('ParsedownExtra')) { + throw new LogicalException('ParsedownExtra class not found. Install erusev/parsedown-extra package.'); + } + + $builder = $this->getContainerBuilder(); + + $builder->addDefinition($this->prefix('adapter')) + ->setFactory(ParsedownExtraAdapter::class); + } + + public function beforeCompile(): void + { + $builder = $this->getContainerBuilder(); + $config = $this->config; + + if ($builder->getByType(LatteFactory::class) === null) { + throw new LogicalException('You have to register LatteFactory first.'); + } + + $factoryDefinition = $builder->getDefinitionByType(LatteFactory::class); + assert($factoryDefinition instanceof FactoryDefinition); + + $factoryDefinition + ->getResultDefinition() + ->addSetup('addFilter', [$config->filter, [new Statement(ParsedownFilter::class), 'apply']]); + } + +} diff --git a/src/Filters/ParsedownExtraAdapter.php b/src/Filters/ParsedownExtraAdapter.php new file mode 100644 index 0000000..8981ea9 --- /dev/null +++ b/src/Filters/ParsedownExtraAdapter.php @@ -0,0 +1,43 @@ +parsedown = $parsedown ?? new ParsedownExtra(); + } + + public function process(mixed $text): mixed + { + foreach ($this->onProcess as $callback) { + $callback($text, $this); + } + + return $this->parsedown->parse($text); + } + + public function processLine(mixed $line): string + { + foreach ($this->onProcess as $callback) { + $callback($line, $this); + } + + return $this->parsedown->line($line); + } + +} diff --git a/src/Filters/ParsedownFilter.php b/src/Filters/ParsedownFilter.php new file mode 100644 index 0000000..d782124 --- /dev/null +++ b/src/Filters/ParsedownFilter.php @@ -0,0 +1,28 @@ +adapter = $adapter; + } + + public function apply(FilterInfo $info, mixed $text): mixed + { + if ($info->contentType !== ContentType::Html) { + throw new LogicalException('Filter |parsedown used in incompatible content type.'); + } + + return $this->adapter->process($text); + } + +} From 701e75c589fc3f4a32847d22baf4d84d3a0b9f1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 19:21:21 +0000 Subject: [PATCH 2/8] Add tests and documentation for Parsedown support - Add unit tests for ParsedownExtraAdapter, ParsedownFilter and ParsedownExtension - Add FakeParsedownExtra fixture for testing - Fix ParsedownFilter to handle Text content type and set output to Html - Add documentation for ParsedownExtension in .docs/README.md --- .docs/README.md | 54 +++++++++++++ src/Filters/ParsedownFilter.php | 4 +- tests/Cases/DI/ParsedownExtension.phpt | 76 +++++++++++++++++++ .../Cases/Filters/ParsedownExtraAdapter.phpt | 64 ++++++++++++++++ tests/Cases/Filters/ParsedownFilter.phpt | 59 ++++++++++++++ tests/Fixtures/FakeParsedownExtra.php | 26 +++++++ 6 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 tests/Cases/DI/ParsedownExtension.phpt create mode 100644 tests/Cases/Filters/ParsedownExtraAdapter.phpt create mode 100644 tests/Cases/Filters/ParsedownFilter.phpt create mode 100644 tests/Fixtures/FakeParsedownExtra.php diff --git a/.docs/README.md b/.docs/README.md index fa625ae..b6cf611 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -7,6 +7,7 @@ Extra contribution to [`nette/latte`](https://github.com/nette/latte). - [Setup](#setup) - [VersionExtension - revision macros for assets](#versions-extension) - [CdnExtension - CDN support for assets](#cdn-extension) +- [ParsedownExtension - markdown parsing support](#parsedown-extension) - [FiltersExtension - install filters easily](#filters-extension) - [RuntimeFilters - collection of prepared filters](#runtimefilters) - [Formatters - collection of prepared formatters](#formatters) @@ -92,6 +93,59 @@ cdn: https://cdn.example.com/assets/style.css?time=123456789 ``` +## Parsedown Extension + +This extension provides markdown parsing support via the `|parsedown` filter using [ParsedownExtra](https://github.com/erusev/parsedown-extra). + +### Requirements + +The `erusev/parsedown-extra` package is an optional dependency. Install it first: + +```bash +composer require erusev/parsedown-extra +``` + +### Install + +```neon +extensions: + parsedown: Contributte\Latte\DI\ParsedownExtension +``` + +### Configuration + +```neon +parsedown: + filter: parsedown # default filter name, can be changed to e.g. "markdown" +``` + +### Usage + +```latte +{* Filter syntax *} +{$markdownContent|parsedown} + +{* Block syntax *} +{block|parsedown} +# Hello World + +This is **markdown** content. +{/block} +``` + +### Advanced Usage + +You can use the `ParsedownExtraAdapter` directly with callbacks for custom processing: + +```php +use Contributte\Latte\Filters\ParsedownExtraAdapter; + +$adapter = $container->getByType(ParsedownExtraAdapter::class); +$adapter->onProcess[] = function (string $text, ParsedownExtraAdapter $adapter): void { + // Custom processing before markdown parsing +}; +``` + ## Filters Extension Install filters by single extension and simple `FiltersProvider` implementation. diff --git a/src/Filters/ParsedownFilter.php b/src/Filters/ParsedownFilter.php index d782124..c9cca17 100644 --- a/src/Filters/ParsedownFilter.php +++ b/src/Filters/ParsedownFilter.php @@ -18,10 +18,12 @@ public function __construct(ParsedownExtraAdapter $adapter) public function apply(FilterInfo $info, mixed $text): mixed { - if ($info->contentType !== ContentType::Html) { + if ($info->contentType !== null && $info->contentType !== ContentType::Html && $info->contentType !== ContentType::Text) { throw new LogicalException('Filter |parsedown used in incompatible content type.'); } + $info->contentType = ContentType::Html; + return $this->adapter->process($text); } diff --git a/tests/Cases/DI/ParsedownExtension.phpt b/tests/Cases/DI/ParsedownExtension.phpt new file mode 100644 index 0000000..5a63ec2 --- /dev/null +++ b/tests/Cases/DI/ParsedownExtension.phpt @@ -0,0 +1,76 @@ +load(function (Compiler $compiler): void { + $compiler->addExtension('latte', new LatteExtension(Environment::getTestDir())); + $compiler->addExtension('parsedown', new ParsedownExtension()); + }, 1); + + /** @var Container $container */ + $container = new $class(); + + // Test adapter is registered + $adapter = $container->getByType(ParsedownExtraAdapter::class); + Assert::type(ParsedownExtraAdapter::class, $adapter); + + // Test filter is registered + /** @var ILatteFactory $latteFactory */ + $latteFactory = $container->getByType(ILatteFactory::class); + $result = $latteFactory->create()->renderToString(FileMock::create('{="Hello World"|parsedown}', 'latte')); + Assert::equal('

Hello World

', $result); +}); + +// Test DI extension with custom filter name +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('latte', new LatteExtension(Environment::getTestDir())); + $compiler->addExtension('parsedown', new ParsedownExtension()); + $compiler->loadConfig(FileMock::create(' + parsedown: + filter: markdown + ', 'neon')); + }, 2); + + /** @var Container $container */ + $container = new $class(); + + /** @var ILatteFactory $latteFactory */ + $latteFactory = $container->getByType(ILatteFactory::class); + $result = $latteFactory->create()->renderToString(FileMock::create('{="Hello World"|markdown}', 'latte')); + Assert::equal('

Hello World

', $result); +}); + +// Test DI extension with block syntax +Toolkit::test(function (): void { + $loader = new ContainerLoader(Environment::getTestDir(), true); + $class = $loader->load(function (Compiler $compiler): void { + $compiler->addExtension('latte', new LatteExtension(Environment::getTestDir())); + $compiler->addExtension('parsedown', new ParsedownExtension()); + }, 3); + + /** @var Container $container */ + $container = new $class(); + + /** @var ILatteFactory $latteFactory */ + $latteFactory = $container->getByType(ILatteFactory::class); + $result = $latteFactory->create()->renderToString(FileMock::create('{block|parsedown}Hello World{/block}', 'latte')); + Assert::equal('

Hello World

', $result); +}); diff --git a/tests/Cases/Filters/ParsedownExtraAdapter.phpt b/tests/Cases/Filters/ParsedownExtraAdapter.phpt new file mode 100644 index 0000000..8b489b5 --- /dev/null +++ b/tests/Cases/Filters/ParsedownExtraAdapter.phpt @@ -0,0 +1,64 @@ +process('Hello World'); + Assert::equal('

Hello World

', $result); +}); + +// Test line processing +Toolkit::test(function (): void { + $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); + $result = $adapter->processLine('Hello World'); + Assert::equal('Hello World', $result); +}); + +// Test onProcess callback +Toolkit::test(function (): void { + $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); + $called = false; + $adapter->onProcess[] = function (string $text, ParsedownExtraAdapter $a) use (&$called): void { + $called = true; + Assert::equal('Test', $text); + }; + + $adapter->process('Test'); + Assert::true($called); +}); + +// Test onProcess callback for line +Toolkit::test(function (): void { + $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); + $called = false; + $adapter->onProcess[] = function (string $line, ParsedownExtraAdapter $a) use (&$called): void { + $called = true; + Assert::equal('Test Line', $line); + }; + + $adapter->processLine('Test Line'); + Assert::true($called); +}); + +// Test multiple onProcess callbacks +Toolkit::test(function (): void { + $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); + $callCount = 0; + $adapter->onProcess[] = function () use (&$callCount): void { + $callCount++; + }; + $adapter->onProcess[] = function () use (&$callCount): void { + $callCount++; + }; + + $adapter->process('Test'); + Assert::equal(2, $callCount); +}); diff --git a/tests/Cases/Filters/ParsedownFilter.phpt b/tests/Cases/Filters/ParsedownFilter.phpt new file mode 100644 index 0000000..ca8c162 --- /dev/null +++ b/tests/Cases/Filters/ParsedownFilter.phpt @@ -0,0 +1,59 @@ +apply($info, 'Hello World'); + + Assert::equal('

Hello World

', $result); +}); + +// Test filter works with Text content type and converts to Html +Toolkit::test(function (): void { + $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); + $filter = new ParsedownFilter($adapter); + + $info = new FilterInfo(ContentType::Text); + $result = $filter->apply($info, 'Hello World'); + + Assert::equal('

Hello World

', $result); + Assert::equal(ContentType::Html, $info->contentType); +}); + +// Test filter throws exception for incompatible content type (e.g., JavaScript) +Toolkit::test(function (): void { + $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); + $filter = new ParsedownFilter($adapter); + + $info = new FilterInfo(ContentType::JavaScript); + + Assert::exception(function () use ($filter, $info): void { + $filter->apply($info, 'Hello World'); + }, LogicalException::class, 'Filter |parsedown used in incompatible content type.'); +}); + +// Test filter with markdown-like content +Toolkit::test(function (): void { + $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); + $filter = new ParsedownFilter($adapter); + + $info = new FilterInfo(ContentType::Html); + $result = $filter->apply($info, '# Heading'); + + Assert::equal('

# Heading

', $result); +}); diff --git a/tests/Fixtures/FakeParsedownExtra.php b/tests/Fixtures/FakeParsedownExtra.php new file mode 100644 index 0000000..3c8ad9a --- /dev/null +++ b/tests/Fixtures/FakeParsedownExtra.php @@ -0,0 +1,26 @@ +' . $text . '

'; + } + + public function line(string $line): string + { + return $line; + } + +} + +// Register as ParsedownExtra if the real one is not available +if (!class_exists('ParsedownExtra')) { + class_alias(FakeParsedownExtra::class, 'ParsedownExtra'); +} From 5383272778aac084f2b1f6ccc491189f469dc4ac Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 14 Dec 2025 19:45:30 +0000 Subject: [PATCH 3/8] Modernize build tools and static analysis configuration - Consolidate .PHONY declarations in Makefile - Update phpstan.neon to PHP 8.2 and add ignoreErrors for optional ParsedownExtra dependency - Update ruleset.xml to use contributte/qa ruleset-8.2.xml --- Makefile | 15 +++++---------- phpstan.neon | 6 +++++- ruleset.xml | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 33bc117..d53bea0 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,26 @@ -.PHONY: install +.PHONY: install qa cs csf phpstan tests coverage + install: composer update -.PHONY: qa qa: phpstan cs -.PHONY: cs cs: ifdef GITHUB_ACTION - vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp -q --report=checkstyle src tests | cs2pr + vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt -q --report=checkstyle src tests | cs2pr else - vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests + vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt src tests endif -.PHONY: csf csf: - vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --extensions="php,phpt" --colors -nsp src tests + vendor/bin/phpcbf --standard=ruleset.xml --encoding=utf-8 --colors -nsp --extensions=php,phpt src tests -.PHONY: phpstan phpstan: vendor/bin/phpstan analyse -c phpstan.neon -.PHONY: tests tests: vendor/bin/tester -s -p php --colors 1 -C tests/Cases -.PHONY: coverage coverage: ifdef GITHUB_ACTION vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage coverage.xml --coverage-src src tests/Cases diff --git a/phpstan.neon b/phpstan.neon index 562ab05..19f0196 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,7 +3,7 @@ includes: parameters: level: 9 - phpVersion: 80100 + phpVersion: 80200 scanDirectories: - src @@ -16,3 +16,7 @@ parameters: - .docs ignoreErrors: + # ParsedownExtra is an optional dependency + - '#unknown class ParsedownExtra#i' + - '#invalid type ParsedownExtra#i' + - '#Instantiated class ParsedownExtra not found#' diff --git a/ruleset.xml b/ruleset.xml index 629d95e..6485eb5 100644 --- a/ruleset.xml +++ b/ruleset.xml @@ -1,7 +1,7 @@ - + From 28ec18e0142d72720ac088fd764d47d237c1c0de Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 14 Dec 2025 19:45:39 +0000 Subject: [PATCH 4/8] Update CI workflows to PHP 8.3 - Update phpstan.yml to use PHP 8.3 - Update codesniffer.yml to use PHP 8.3 - Update coverage.yml to use PHP 8.3 --- .github/workflows/codesniffer.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/phpstan.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codesniffer.yml b/.github/workflows/codesniffer.yml index a58ac4f..d5ff803 100644 --- a/.github/workflows/codesniffer.yml +++ b/.github/workflows/codesniffer.yml @@ -15,4 +15,4 @@ jobs: name: "Codesniffer" uses: contributte/.github/.github/workflows/codesniffer.yml@master with: - php: "8.2" + php: "8.3" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 860c47e..85f1300 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -15,4 +15,4 @@ jobs: name: "Nette Tester" uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master with: - php: "8.2" + php: "8.3" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index eb916bf..9827fdd 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -15,4 +15,4 @@ jobs: name: "Phpstan" uses: contributte/.github/.github/workflows/phpstan.yml@master with: - php: "8.2" + php: "8.3" From 5ece3536496b2bd844680b70e305249bf10139ea Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 14 Dec 2025 19:45:48 +0000 Subject: [PATCH 5/8] Update README.md with correct PHP version requirements - Fix PHP version in versions table (dev requires PHP 8.2+) - Align table formatting --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 59c7e4d..640d875 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ For details on how to use this package, check out our [documentation](.docs). ## Versions -| State | Version | Branch | Nette | PHP | -|-------------|---------|----------|-------|----------| -| dev | `^0.7` | `master` | 3.2+ | `>=8.1+` | -| stable | `^0.6` | `master` | 3.2+ | `>=8.1+` | +| State | Version | Branch | Nette | PHP | +|-------------|---------|----------|-------|---------| +| dev | `^0.7` | `master` | 3.2+ | `>=8.2` | +| stable | `^0.6` | `master` | 3.2+ | `>=8.1` | ## Development From cf929a0ac5dca3dd4d29adbc5bcbfd2c4e3785b0 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 14 Dec 2025 19:51:22 +0000 Subject: [PATCH 6/8] Fix CdnNode for Latte 3.1 compatibility Replace LR\Filters::escapeHtmlAttr with LR\HtmlHelpers::escapeAttr as the Filters class was restructured in Latte 3.1. --- src/Extensions/Node/CdnNode.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Extensions/Node/CdnNode.php b/src/Extensions/Node/CdnNode.php index c5cb34c..2e0bb7b 100644 --- a/src/Extensions/Node/CdnNode.php +++ b/src/Extensions/Node/CdnNode.php @@ -24,7 +24,7 @@ public static function create(Tag $tag): self public function print(PrintContext $context): string { return $context->format( - 'echo LR\Filters::escapeHtmlAttr(call_user_func($this->global->cdnBuilder, %0.node)) %1.line;', + 'echo LR\HtmlHelpers::escapeAttr(call_user_func($this->global->cdnBuilder, %0.node)) %1.line;', $this->path, $this->position, ); From 6f502b081450470a39503433d8e417c536a048c3 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 14 Dec 2025 19:51:31 +0000 Subject: [PATCH 7/8] Fix code style in ParsedownExtraAdapter tests Use stdClass objects instead of reference variables to comply with coding standard rules. --- .../Cases/Filters/ParsedownExtraAdapter.phpt | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/Cases/Filters/ParsedownExtraAdapter.phpt b/tests/Cases/Filters/ParsedownExtraAdapter.phpt index 8b489b5..93eda9b 100644 --- a/tests/Cases/Filters/ParsedownExtraAdapter.phpt +++ b/tests/Cases/Filters/ParsedownExtraAdapter.phpt @@ -25,40 +25,43 @@ Toolkit::test(function (): void { // Test onProcess callback Toolkit::test(function (): void { $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); - $called = false; - $adapter->onProcess[] = function (string $text, ParsedownExtraAdapter $a) use (&$called): void { - $called = true; + $state = new stdClass(); + $state->called = false; + $adapter->onProcess[] = function (string $text, ParsedownExtraAdapter $a) use ($state): void { + $state->called = true; Assert::equal('Test', $text); }; $adapter->process('Test'); - Assert::true($called); + Assert::true($state->called); }); // Test onProcess callback for line Toolkit::test(function (): void { $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); - $called = false; - $adapter->onProcess[] = function (string $line, ParsedownExtraAdapter $a) use (&$called): void { - $called = true; + $state = new stdClass(); + $state->called = false; + $adapter->onProcess[] = function (string $line, ParsedownExtraAdapter $a) use ($state): void { + $state->called = true; Assert::equal('Test Line', $line); }; $adapter->processLine('Test Line'); - Assert::true($called); + Assert::true($state->called); }); // Test multiple onProcess callbacks Toolkit::test(function (): void { $adapter = new ParsedownExtraAdapter(new FakeParsedownExtra()); - $callCount = 0; - $adapter->onProcess[] = function () use (&$callCount): void { - $callCount++; + $state = new stdClass(); + $state->callCount = 0; + $adapter->onProcess[] = function () use ($state): void { + $state->callCount++; }; - $adapter->onProcess[] = function () use (&$callCount): void { - $callCount++; + $adapter->onProcess[] = function () use ($state): void { + $state->callCount++; }; $adapter->process('Test'); - Assert::equal(2, $callCount); + Assert::equal(2, $state->callCount); }); From 7183051d477ee27eb8af667b87dd920e3d7254f7 Mon Sep 17 00:00:00 2001 From: Felix Date: Sun, 14 Dec 2025 19:55:37 +0000 Subject: [PATCH 8/8] Bump minimum latte/latte version to 3.1.0 Required for LR\HtmlHelpers::escapeAttr which replaced LR\Filters::escapeHtmlAttr in Latte 3.1. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 08b94de..4a04ee7 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ ], "require": { "php": ">=8.2", - "latte/latte": "^3.0.12" + "latte/latte": "^3.1.0" }, "require-dev": { "nette/application": "^3.1.14",