diff --git a/.phpunit.result.cache b/.phpunit.result.cache index bd62f82a..a8e734a0 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":1,"defects":{"Tests\\Highlight\\Patterns\\Php\\ParameterTypeTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\MultilineDocCommentTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\MultilineSingleDocCommentTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\SinglelineDocCommentTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\ClassPropertyTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\PropertyAccessTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\FunctionNameTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\FunctionCallTokenPatternTest::test_pattern":7,"Tests\\Highlight\\RenderTokensTest::test_nested_tokens_b":7,"Tests\\Highlight\\RenderTokensTest::test_nested_tokens_c":7,"Tests\\Highlight\\Patterns\\Php\\AttributeTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Php\\AttributeTypeTokenPatternTest::test_pattern":7,"Tests\\Highlight\\Injections\\PhpShortEchoInjectionTest::test_injection":7,"Tests\\Highlight\\Patterns\\Html\\OpenTagPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Html\\TagAttributePatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Html\\HtmlCommentPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Css\\CssCommentPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Css\\SelectorPatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Css\\CssAttributePatternTest::test_pattern":7,"Tests\\Highlight\\Patterns\\Css\\CssFunctionPatternTest::test_pattern":7},"times":{"Tests\\Highlight\\Patterns\\Php\\ImplementsTokenPatternTest::testGetPattern":0.005,"Tests\\Highlight\\Patterns\\Php\\AttributeTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\ImplementsTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\ExtendsTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\UseTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\NamespaceTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\PropertyTypesTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\ClassNameTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\ReturnTypeTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\StaticClassCallTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\ParameterTypeTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\NewObjectTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\MultilineDocCommentTokenPatternTest::test_pattern":0.005,"Tests\\Highlight\\Patterns\\Php\\MultilineSingleDocCommentTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\SinglelineDocCommentTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\ClassPropertyTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\NamedArgumentTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\PropertyAccessTokenPatternTest::test_pattern":0.001,"Tests\\Highlight\\Patterns\\Php\\FunctionNameTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\FunctionCallTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\ConstantPropertyTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\KeywordTokenPatternTest::test_pattern":0,"Tests\\Highlight\\TokenTest::test_contains":0.001,"Tests\\Highlight\\RenderTokensTest::test_nested_tokens_b":0,"Tests\\Highlight\\RenderTokensTest::test_nested_tokens":0,"Tests\\Highlight\\RenderTokensTest::test_nested_tokens_c":0,"Tests\\Highlight\\HighlighterTest::test_highlight#0":0.005,"Tests\\Highlight\\Patterns\\Php\\MultilineDoubleDocCommentTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\NestedFunctionCallTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Patterns\\Php\\AttributeTypeTokenPatternTest::test_pattern":0,"Tests\\Highlight\\Injections\\PhpInjectionTest::test_injection":0.01,"Tests\\Highlight\\Injections\\PhpShortEchoInjectionTest::test_injection":0.009,"Tests\\Highlight\\Patterns\\Html\\OpenTagPatternTest::test_pattern":0.004,"Tests\\Highlight\\Patterns\\Html\\CloseTagPatternTest::test_pattern":0.005,"Tests\\Highlight\\Patterns\\Html\\TagAttributePatternTest::test_pattern":0.004,"Tests\\Highlight\\Patterns\\Html\\HtmlCommentPatternTest::test_pattern":0.004,"Tests\\Highlight\\Patterns\\Css\\CssCommentPatternTest::test_pattern":0.002,"Tests\\Highlight\\Patterns\\Php\\MultilineDoubleDocCommentPatternTest::test_pattern":0.003,"Tests\\Highlight\\Patterns\\Css\\SelectorPatternTest::test_pattern":0.002,"Tests\\Highlight\\Patterns\\Css\\CssAttributePatternTest::test_pattern":0.002,"Tests\\Highlight\\Patterns\\Css\\CssFunctionPatternTest::test_pattern":0.003,"Tests\\Highlight\\Injections\\CssInjectionTest::test_injection":0.004}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Markdown\\Extensions\\GithubLink\\GithubLinkRuleTest::test_parse":7,"Tests\\Markdown\\Extensions\\GithubLink\\GithubLinkTokenTest::test_with_attribute_and_leading_slash":7},"times":{"Tests\\Markdown\\Extensions\\GithubLink\\GithubLinkRuleTest::test_parse":0.001,"Tests\\Markdown\\Extensions\\GithubLink\\GithubLinkTokenTest::test_with_class":0.004,"Tests\\Markdown\\Extensions\\GithubLink\\GithubLinkTokenTest::test_with_class_and_leading_slash":0,"Tests\\Markdown\\Extensions\\GithubLink\\GithubLinkTokenTest::test_with_attribute_and_leading_slash":0,"Tests\\Markdown\\Extensions\\GithubLink\\GithubLinkRuleTest::test_parse_without_b":0.003,"Tests\\Markdown\\Extensions\\GitHubLink\\GitHubLinkTokenTest::test_with_class":0.01,"Tests\\Markdown\\Extensions\\GitHubLink\\GitHubLinkTokenTest::test_with_class_and_leading_slash":0,"Tests\\Markdown\\Extensions\\GitHubLink\\GitHubLinkTokenTest::test_with_attribute_and_leading_slash":0}} \ No newline at end of file diff --git a/composer.json b/composer.json index 9307d9a9..0b8fdadb 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,11 @@ "require": { "tempest/framework": "^3.11", "league/commonmark": "^2.8.0", - "symfony/yaml": "^7.4.1", + "symfony/yaml": "^8.0", "spatie/yaml-front-matter": "^2.1.1", "spatie/browsershot": "^5.2.0", - "assertchris/ellison": "^1.0.2" + "assertchris/ellison": "^1.0.2", + "tempest/markdown": "dev-main" }, "require-dev": { "phpunit/phpunit": "^12.5.4", diff --git a/composer.lock b/composer.lock index 631284b1..3466c18d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ba6aeaad98a2c022f559eb8e75244076", + "content-hash": "c43d6cf747b6e574f8f3c0ef3fccf9e4", "packages": [ { "name": "assertchris/ellison", @@ -974,6 +974,150 @@ ], "time": "2026-06-02T12:30:48+00:00" }, + { + "name": "intervention/gif", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/bb395af960deffe64d70c976b4df9283f68e762d", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/5.0.1" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-05-03T06:04:47+00:00" + }, + { + "name": "intervention/image", + "version": "4.1.2", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "ba4a7cc8042882d479a78b0835f3f0e991e40a71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/ba4a7cc8042882d479a78b0835f3f0e991e40a71", + "reference": "ba4a7cc8042882d479a78b0835f3f0e991e40a71", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^5", + "php": "^8.3" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^4" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io" + } + ], + "description": "PHP Image Processing", + "homepage": "https://image.intervention.io", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/4.1.2" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2026-05-23T06:51:28+00:00" + }, { "name": "laminas/laminas-diactoros", "version": "3.8.0", @@ -4530,28 +4674,28 @@ }, { "name": "symfony/yaml", - "version": "v7.4.13", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", - "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -4582,7 +4726,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.13" + "source": "https://github.com/symfony/yaml/tree/v8.1.0" }, "funding": [ { @@ -4602,7 +4746,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T06:06:12+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "tempest/framework", @@ -4901,6 +5045,120 @@ ], "time": "2026-05-19T07:56:07+00:00" }, + { + "name": "tempest/markdown", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/tempestphp/markdown.git", + "reference": "ecc6d966bb0f82f86df6d41403c96309565cb96f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tempestphp/markdown/zipball/ecc6d966bb0f82f86df6d41403c96309565cb96f", + "reference": "ecc6d966bb0f82f86df6d41403c96309565cb96f", + "shasum": "" + }, + "require": { + "php": "^8.5", + "symfony/yaml": "^8.0", + "tempest/highlight": "^2.23.1", + "tempest/responsive-image": "dev-main" + }, + "require-dev": { + "carthage-software/mago": "1.16.0", + "erusev/parsedown-extra": "^0.9.0", + "league/commonmark": "^2.8", + "michelf/php-markdown": "^2.0", + "phpbench/phpbench": "^1.4", + "phpunit/phpunit": "^12.0", + "tempest/debug": "^3.10" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Tempest\\Markdown\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brent Roose", + "email": "brendt@stitcher.io" + } + ], + "description": "Fast and extensible Markdown in PHP", + "support": { + "issues": "https://github.com/tempestphp/markdown/issues", + "source": "https://github.com/tempestphp/markdown/tree/main" + }, + "funding": [ + { + "url": "https://github.com/brendt", + "type": "github" + } + ], + "time": "2026-06-04T08:09:44+00:00" + }, + { + "name": "tempest/responsive-image", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/tempestphp/responsive-image.git", + "reference": "a0059b6129650771ccbbb8210147d664bed489fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tempestphp/responsive-image/zipball/a0059b6129650771ccbbb8210147d664bed489fa", + "reference": "a0059b6129650771ccbbb8210147d664bed489fa", + "shasum": "" + }, + "require": { + "intervention/image": "^4.1", + "php": "^8.5" + }, + "require-dev": { + "carthage-software/mago": "1.16.0", + "phpbench/phpbench": "^1.4", + "phpunit/phpunit": "^12.0", + "tempest/command-bus": "^3.11", + "tempest/debug": "^3.11" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Tempest\\ResponsiveImage\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brent Roose", + "email": "brendt@stitcher.io" + } + ], + "description": "Server-side responsive images with PHP", + "support": { + "issues": "https://github.com/tempestphp/responsive-image/issues", + "source": "https://github.com/tempestphp/responsive-image/tree/main" + }, + "funding": [ + { + "url": "https://github.com/brendt", + "type": "github" + } + ], + "time": "2026-05-29T12:38:09+00:00" + }, { "name": "vlucas/phpdotenv", "version": "v5.6.3", @@ -5645,16 +5903,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.28", + "version": "12.5.29", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4" + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4", - "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a", + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a", "shasum": "" }, "require": { @@ -5668,7 +5926,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-code-coverage": "^12.5.7", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -5678,7 +5936,7 @@ "sebastian/diff": "^7.0.0", "sebastian/environment": "^8.1.2", "sebastian/exporter": "^7.0.3", - "sebastian/global-state": "^8.0.2", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.4", @@ -5723,7 +5981,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29" }, "funding": [ { @@ -5731,7 +5989,7 @@ "type": "other" } ], - "time": "2026-05-27T14:01:10+00:00" + "time": "2026-06-04T06:14:42+00:00" }, { "name": "sebastian/cli-parser", @@ -6747,7 +7005,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "tempest/markdown": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": {}, diff --git a/public/favicon/android-chrome-192x192.png b/public/favicon/android-chrome-192x192.png index b0ab3cb4..aa8059f0 100644 Binary files a/public/favicon/android-chrome-192x192.png and b/public/favicon/android-chrome-192x192.png differ diff --git a/public/favicon/android-chrome-512x512.png b/public/favicon/android-chrome-512x512.png index dab06be6..6e71cadb 100644 Binary files a/public/favicon/android-chrome-512x512.png and b/public/favicon/android-chrome-512x512.png differ diff --git a/public/favicon/apple-touch-icon.png b/public/favicon/apple-touch-icon.png index 323dc7e8..af7457c5 100644 Binary files a/public/favicon/apple-touch-icon.png and b/public/favicon/apple-touch-icon.png differ diff --git a/public/favicon/favicon-16x16.png b/public/favicon/favicon-16x16.png index 5244596b..f099f479 100644 Binary files a/public/favicon/favicon-16x16.png and b/public/favicon/favicon-16x16.png differ diff --git a/public/favicon/favicon-32x32.png b/public/favicon/favicon-32x32.png index acaa6ba5..b8c471e3 100644 Binary files a/public/favicon/favicon-32x32.png and b/public/favicon/favicon-32x32.png differ diff --git a/public/favicon/favicon.ico b/public/favicon/favicon.ico index 925fca35..f44654b5 100644 Binary files a/public/favicon/favicon.ico and b/public/favicon/favicon.ico differ diff --git a/public/img/tempest-logo.png b/public/img/tempest-logo.png new file mode 100644 index 00000000..a9bc3233 Binary files /dev/null and b/public/img/tempest-logo.png differ diff --git a/src/Highlight/HighlighterInitializer.php b/src/Highlight/HighlighterInitializer.php index 1e20540b..28315951 100644 --- a/src/Highlight/HighlighterInitializer.php +++ b/src/Highlight/HighlighterInitializer.php @@ -9,6 +9,7 @@ use Tempest\Container\Initializer; use Tempest\Container\Singleton; use Tempest\Highlight\Highlighter; +use Tempest\Highlight\Languages\Php\PhpLanguage; use Tempest\Highlight\Themes\CssTheme; final readonly class HighlighterInitializer implements Initializer @@ -17,7 +18,10 @@ #[Singleton(tag: 'project')] public function initialize(Container $container): Highlighter { - $highlighter = new Highlighter(new CssTheme()); + $highlighter = new Highlighter( + theme: new CssTheme(), + fallbackLanguage: new PhpLanguage(), + ); $highlighter ->addLanguage(new TempestViewLanguage()) diff --git a/src/Markdown/Alerts/AlertBlock.php b/src/Markdown/Alerts/AlertBlock.php deleted file mode 100644 index e1798a7e..00000000 --- a/src/Markdown/Alerts/AlertBlock.php +++ /dev/null @@ -1,18 +0,0 @@ -block = new AlertBlock($alertType, $icon, $title); - } - - #[Override] - public function addLine(string $line): void {} - - #[Override] - public function getBlock(): AbstractBlock - { - return $this->block; - } - - #[Override] - public function isContainer(): bool - { - return true; - } - - #[Override] - public function canContain(AbstractBlock $childBlock): bool - { - return true; - } - - #[Override] - public function canHaveLazyContinuationLines(): bool - { - return false; - } - - public function parseInlines(): bool - { - return true; - } - - #[Override] - public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue - { - if ($cursor->isIndented()) { - return BlockContinue::at($cursor); - } - - $match = RegexHelper::matchFirst('/^:::$/', $cursor->getLine()); - if ($match !== null) { - $this->finished = true; - return BlockContinue::finished(); - } - - return BlockContinue::at($cursor); - } - - #[Override] - public function closeBlock(): void - { - // Nothing to do here - } - - public function isFinished(): bool - { - return $this->finished; - } -} diff --git a/src/Markdown/Alerts/AlertBlockRenderer.php b/src/Markdown/Alerts/AlertBlockRenderer.php deleted file mode 100644 index 10b85208..00000000 --- a/src/Markdown/Alerts/AlertBlockRenderer.php +++ /dev/null @@ -1,70 +0,0 @@ -icon) { - 'false' => null, - null => match ($node->alertType) { - 'warning' => 'tabler:exclamation-circle', - 'info' => 'tabler:info-circle', - 'success' => 'tabler:check-circle', - 'error' => 'tabler:exclamation-circle', - default => null, - }, - default => $node->icon, - }; - - $icon = $iconName - ? get(ViewRenderer::class)->render(view('', name: $iconName)) - : null; - - $content = new HtmlElement( - tagName: 'div', - attributes: ['class' => 'alert-wrapper'], - contents: [ - $icon ? new HtmlElement('div', attributes: ['class' => 'alert-icon-wrapper'], contents: $icon) : null, - new HtmlElement( - tagName: 'div', - attributes: ['class' => 'alert-content'], - contents: [ - $node->title ? new HtmlElement('span', attributes: ['class' => 'alert-title'], contents: $node->title) : null, - $childRenderer->renderNodes($node->children()), - ], - ), - ], - ); - - return new HtmlElement( - 'div', - ['class' => "alert alert-{$node->alertType}"], - $content, - ); - } -} diff --git a/src/Markdown/Alerts/AlertBlockStartParser.php b/src/Markdown/Alerts/AlertBlockStartParser.php deleted file mode 100644 index b31d1152..00000000 --- a/src/Markdown/Alerts/AlertBlockStartParser.php +++ /dev/null @@ -1,41 +0,0 @@ -isIndented()) { - return BlockStart::none(); - } - - $match = RegexHelper::matchFirst('/^:::(?!group)(?[a-z]+)({(?.*?)})? ?(?.*?)$/i', $cursor->getLine()); - - if ($match === null) { - return BlockStart::none(); - } - - if (str_starts_with($match[0], needle: ':::code-group')) { - return BlockStart::none(); - } - - $cursor->advanceToEnd(); - - $alertType = $match['type']; - $icon = $match['icon'] ?: null; - $title = $match['title'] ?: null; - - return BlockStart::of(new AlertBlockParser($alertType, $icon, $title))->at($cursor); - } -} diff --git a/src/Markdown/Alerts/AlertExtension.php b/src/Markdown/Alerts/AlertExtension.php deleted file mode 100644 index dbaf38f2..00000000 --- a/src/Markdown/Alerts/AlertExtension.php +++ /dev/null @@ -1,19 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\Alerts; - -use League\CommonMark\Environment\EnvironmentBuilderInterface; -use League\CommonMark\Extension\ExtensionInterface; -use Override; - -final class AlertExtension implements ExtensionInterface -{ - #[Override] - public function register(EnvironmentBuilderInterface $environment): void - { - $environment->addBlockStartParser(new AlertBlockStartParser()); - $environment->addRenderer(AlertBlock::class, new AlertBlockRenderer()); - } -} diff --git a/src/Markdown/CodeBlocks/CodeBlockRenderer.php b/src/Markdown/CodeBlocks/CodeBlockRenderer.php deleted file mode 100644 index 77bb061a..00000000 --- a/src/Markdown/CodeBlocks/CodeBlockRenderer.php +++ /dev/null @@ -1,65 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\CodeBlocks; - -use App\Markdown\ResolvesInfoWords; -use InvalidArgumentException; -use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; -use League\CommonMark\Node\Node; -use League\CommonMark\Renderer\ChildNodeRendererInterface; -use League\CommonMark\Renderer\NodeRendererInterface; -use Override; -use Tempest\Highlight\Highlighter; -use Tempest\Highlight\WebTheme; - -final class CodeBlockRenderer implements NodeRendererInterface -{ - use ResolvesInfoWords; - - public function __construct( - private Highlighter $highlighter = new Highlighter(), - ) {} - - #[Override] - public function render(Node $node, ChildNodeRendererInterface $childRenderer): string - { - if (! $node instanceof FencedCode) { - throw new InvalidArgumentException('Block must be instance of ' . FencedCode::class); - } - - preg_match('/^(?<language>[\w]+)(\{(?<startAt>[\d]+)\})?/', $node->getInfoWords()[0] ?? 'txt', $matches); - - $highlighter = $this->highlighter; - - if ($startAt = $matches['startAt'] ?? null) { - $highlighter = $highlighter->withGutter((int) $startAt); - } - - $language = $matches['language'] ?? 'txt'; - $parsed = $highlighter->parse($node->getLiteral(), $language); - $theme = $highlighter->getTheme(); - - if ($theme instanceof WebTheme) { - $pre = $theme->preBefore($highlighter) . $parsed . $theme->preAfter($highlighter); - - if ($this->getInfoWords($node)[1] ?? false) { - return <<<HTML - <div class="code-block named-code-block"> - <div class="code-block-name">{$this->getInfoWords($node)[1]}</div> - {$pre} - </div> - HTML; - } - - return <<<HTML - <div class="code-block named-code-block"> - {$pre} - </div> - HTML; - } - - return '<pre data-lang="' . $language . '" class="notranslate">' . $parsed . '</pre>'; - } -} diff --git a/src/Markdown/CodeBlocks/InlineCodeBlockRenderer.php b/src/Markdown/CodeBlocks/InlineCodeBlockRenderer.php deleted file mode 100644 index 1faecd34..00000000 --- a/src/Markdown/CodeBlocks/InlineCodeBlockRenderer.php +++ /dev/null @@ -1,35 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\CodeBlocks; - -use InvalidArgumentException; -use League\CommonMark\Extension\CommonMark\Node\Inline\Code; -use League\CommonMark\Node\Node; -use League\CommonMark\Renderer\ChildNodeRendererInterface; -use League\CommonMark\Renderer\NodeRendererInterface; -use Override; -use Tempest\Highlight\Highlighter; - -final class InlineCodeBlockRenderer implements NodeRendererInterface -{ - public function __construct( - private Highlighter $highlighter = new Highlighter(), - ) {} - - #[Override] - public function render(Node $node, ChildNodeRendererInterface $childRenderer): ?string - { - if (! $node instanceof Code) { - throw new InvalidArgumentException('Block must be instance of ' . Code::class); - } - - preg_match('/^\{(?<match>[\w]+)}(?<code>.*)/', $node->getLiteral(), $match); - - $language = $match['match'] ?? 'php'; - $code = $match['code'] ?? $node->getLiteral(); - - return '<code>' . $this->highlighter->parse($code, $language) . '</code>'; - } -} diff --git a/src/Markdown/CodeGroups/CodeGroupBlock.php b/src/Markdown/CodeGroups/CodeGroupBlock.php deleted file mode 100644 index 759379b5..00000000 --- a/src/Markdown/CodeGroups/CodeGroupBlock.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\CodeGroups; - -use League\CommonMark\Node\Block\AbstractBlock; - -final class CodeGroupBlock extends AbstractBlock {} diff --git a/src/Markdown/CodeGroups/CodeGroupBlockParser.php b/src/Markdown/CodeGroups/CodeGroupBlockParser.php deleted file mode 100644 index cac5c5e5..00000000 --- a/src/Markdown/CodeGroups/CodeGroupBlockParser.php +++ /dev/null @@ -1,76 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\CodeGroups; - -use League\CommonMark\Node\Block\AbstractBlock; -use League\CommonMark\Parser\Block\BlockContinue; -use League\CommonMark\Parser\Block\BlockContinueParserInterface; -use League\CommonMark\Parser\Cursor; -use League\CommonMark\Util\RegexHelper; -use Override; - -final class CodeGroupBlockParser implements BlockContinueParserInterface -{ - private CodeGroupBlock $block; - private bool $finished = false; - - public function __construct() - { - $this->block = new CodeGroupBlock(); - } - - #[Override] - public function addLine(string $line): void {} - - #[Override] - public function getBlock(): AbstractBlock - { - return $this->block; - } - - #[Override] - public function isContainer(): bool - { - return true; - } - - #[Override] - public function canContain(AbstractBlock $child_block): bool - { - return true; - } - - #[Override] - public function canHaveLazyContinuationLines(): bool - { - return false; - } - - #[Override] - public function tryContinue(Cursor $cursor, BlockContinueParserInterface $active_block_parser): ?BlockContinue - { - if ($cursor->isIndented()) { - return BlockContinue::at($cursor); - } - - $match = RegexHelper::matchFirst('/^:::$/', $cursor->getLine()); - - if ($match !== null) { - $this->finished = true; - - return BlockContinue::finished(); - } - - return BlockContinue::at($cursor); - } - - #[Override] - public function closeBlock(): void {} - - public function isFinished(): bool - { - return $this->finished; - } -} diff --git a/src/Markdown/CodeGroups/CodeGroupBlockRenderer.php b/src/Markdown/CodeGroups/CodeGroupBlockRenderer.php deleted file mode 100644 index 1b017916..00000000 --- a/src/Markdown/CodeGroups/CodeGroupBlockRenderer.php +++ /dev/null @@ -1,97 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\CodeGroups; - -use App\Markdown\CodeBlocks\CodeBlockRenderer; -use App\Markdown\ResolvesInfoWords; -use InvalidArgumentException; -use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; -use League\CommonMark\Node\Node; -use League\CommonMark\Renderer\ChildNodeRendererInterface; -use League\CommonMark\Renderer\NodeRendererInterface; -use League\CommonMark\Util\HtmlElement; -use Override; - -final class CodeGroupBlockRenderer implements NodeRendererInterface -{ - use ResolvesInfoWords; - - public function __construct( - private CodeBlockRenderer $code_block_renderer, - ) {} - - #[Override] - public function render(Node $node, ChildNodeRendererInterface $child_renderer): mixed - { - if (! $node instanceof CodeGroupBlock) { - throw new InvalidArgumentException('Incompatible node type: ' . $node::class); - } - - $tabs = []; - $panels = []; - $index = 0; - - foreach ($node->children() as $child) { - if (! $child instanceof FencedCode) { - continue; - } - - $info_words = $this->getInfoWords($child); - $filename = $info_words[1] ?? "Tab {$index}"; - - $is_active = $index === 0; - $tab_id = 'tab-' . md5($filename . $index); - $panel_id = 'panel-' . md5($filename . $index); - - $tabs[] = new HtmlElement( - tagName: 'button', - attributes: [ - 'class' => 'code-group-tab' . ($is_active ? ' active' : ''), - 'role' => 'tab', - 'aria-selected' => $is_active ? 'true' : 'false', - 'aria-controls' => $panel_id, - 'id' => $tab_id, - 'data-panel' => $panel_id, - ], - contents: $filename, - ); - - $rendered_code = $this->code_block_renderer->render($child, $child_renderer); - - $panels[] = new HtmlElement( - tagName: 'div', - attributes: array_filter([ - 'class' => 'code-group-panel' . ($is_active ? ' active' : ''), - 'role' => 'tabpanel', - 'aria-labelledby' => $tab_id, - 'id' => $panel_id, - 'hidden' => $is_active ? null : 'hidden', - ]), - contents: $rendered_code, - ); - - $index++; - } - - if ($tabs === []) { - return ''; - } - - $tab_list = new HtmlElement( - tagName: 'div', - attributes: [ - 'class' => 'code-group-tabs', - 'role' => 'tablist', - ], - contents: $tabs, - ); - - return new HtmlElement( - tagName: 'div', - attributes: ['class' => 'code-group'], - contents: [$tab_list, ...$panels], - ); - } -} diff --git a/src/Markdown/CodeGroups/CodeGroupBlockStartParser.php b/src/Markdown/CodeGroups/CodeGroupBlockStartParser.php deleted file mode 100644 index 1b3913e7..00000000 --- a/src/Markdown/CodeGroups/CodeGroupBlockStartParser.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\CodeGroups; - -use League\CommonMark\Parser\Block\BlockStart; -use League\CommonMark\Parser\Block\BlockStartParserInterface; -use League\CommonMark\Parser\Cursor; -use League\CommonMark\Parser\MarkdownParserStateInterface; -use League\CommonMark\Util\RegexHelper; -use Override; - -final class CodeGroupBlockStartParser implements BlockStartParserInterface -{ - #[Override] - public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parser_state): ?BlockStart - { - if ($cursor->isIndented()) { - return BlockStart::none(); - } - - $match = RegexHelper::matchFirst('/^:::code-group$/', $cursor->getLine()); - - if ($match === null) { - return BlockStart::none(); - } - - $cursor->advanceToEnd(); - - return BlockStart::of(new CodeGroupBlockParser())->at($cursor); - } -} diff --git a/src/Markdown/CodeGroups/CodeGroupExtension.php b/src/Markdown/CodeGroups/CodeGroupExtension.php deleted file mode 100644 index 7f1fd102..00000000 --- a/src/Markdown/CodeGroups/CodeGroupExtension.php +++ /dev/null @@ -1,18 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\CodeGroups; - -use League\CommonMark\Environment\EnvironmentBuilderInterface; -use League\CommonMark\Extension\ExtensionInterface; -use Override; - -final class CodeGroupExtension implements ExtensionInterface -{ - #[Override] - public function register(EnvironmentBuilderInterface $environment): void - { - $environment->addBlockStartParser(new CodeGroupBlockStartParser()); - } -} diff --git a/src/Markdown/CodeGroups/code-groups.entrypoint.ts b/src/Markdown/CodeGroups/code-groups.entrypoint.ts deleted file mode 100644 index 001c4c2f..00000000 --- a/src/Markdown/CodeGroups/code-groups.entrypoint.ts +++ /dev/null @@ -1,33 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - document.querySelectorAll<HTMLDivElement>('.code-group').forEach((codeGroup) => { - const tabs = codeGroup.querySelectorAll<HTMLButtonElement>('.code-group-tab') - const panels = codeGroup.querySelectorAll<HTMLDivElement>('.code-group-panel') - - tabs.forEach((tab) => { - tab.addEventListener('click', () => { - if (!tab.dataset.panel) { - return - } - - tabs.forEach((tab) => { - tab.classList.remove('active') - tab.setAttribute('aria-selected', 'false') - }) - - panels.forEach((panel) => { - panel.classList.remove('active') - panel.setAttribute('hidden', 'hidden') - }) - - tab.classList.add('active') - tab.setAttribute('aria-selected', 'true') - - const targetPanel = document.getElementById(tab.dataset.panel) - if (targetPanel) { - targetPanel.classList.add('active') - targetPanel.removeAttribute('hidden') - } - }) - }) - }) -}) diff --git a/src/Markdown/Extensions/GitHubLink/GitHubLinkRule.php b/src/Markdown/Extensions/GitHubLink/GitHubLinkRule.php new file mode 100644 index 00000000..4c5cda91 --- /dev/null +++ b/src/Markdown/Extensions/GitHubLink/GitHubLinkRule.php @@ -0,0 +1,33 @@ +<?php + +namespace App\Markdown\Extensions\GitHubLink; + +use App\Web\Documentation\Version; +use Tempest\Markdown\Parser; +use Tempest\Markdown\ProvidesStopChar; +use Tempest\Markdown\Rule; +use Tempest\Markdown\Token; + +final class GitHubLinkRule implements Rule, ProvidesStopChar +{ + public string $stopChar = '{'; + + public function __construct( + private readonly Version $version, + ) {} + + public function shouldParse(Parser $parser): bool + { + return $parser->comesNext('{b`', length: 3) + || $parser->comesNext('{`', length: 2); + } + + public function parse(Parser $parser): ?Token + { + $parser->consumeIncluding('`'); + $content = $parser->consumeUntil('`'); + $parser->consumeIncluding('}'); + + return new GitHubLinkToken($this->version, $content); + } +} \ No newline at end of file diff --git a/src/Markdown/Extensions/GitHubLink/GitHubLinkToken.php b/src/Markdown/Extensions/GitHubLink/GitHubLinkToken.php new file mode 100644 index 00000000..d7fcf715 --- /dev/null +++ b/src/Markdown/Extensions/GitHubLink/GitHubLinkToken.php @@ -0,0 +1,52 @@ +<?php + +namespace App\Markdown\Extensions\GitHubLink; + +use App\Web\Documentation\Version; +use Tempest\Markdown\Parser; +use Tempest\Markdown\Token; +use function Tempest\Support\str; +use function Tempest\Support\Str\to_kebab_case; + +final readonly class GitHubLinkToken implements Token +{ + public function __construct( + private Version $version, + private string $content, + ) {} + + public function parse(Parser $parser): string + { + $uri = str($this->content) + ->stripStart('#[') + ->stripEnd(']') + ->stripStart(['\\Tempest\\', 'Tempest\\']) + ->replaceRegex("/^(\w+)/", static fn (array $matches) => sprintf('packages/%s/src', to_kebab_case($matches[0]))) + ->replaceEvery(['date-time' => 'datetime']) + ->replace('\\', '/') + ->prepend('https://github.com/tempestphp/tempest-framework/blob/' . $this->version->getBranch() . '/') + ->append('.php') + ->toString(); + + if (str_starts_with($this->content, '#[')) { + $text = str($this->content) + ->stripStart('#[') + ->stripEnd(']') + ->stripStart('\\') + ->classBasename() + ->wrap('#[<span class="hl-type">', '</span>]'); + } else { + $text = str($this->content) + ->stripStart('\\') + ->classBasename() + ->wrap('<span class="hl-type">', '</span>') + ->toString(); + } + + return sprintf( + '<a href="%s"><code>%s</code></a>', + $uri, + $text, + ); + } +} \ No newline at end of file diff --git a/src/Markdown/Extensions/Heading/HeadingRule.php b/src/Markdown/Extensions/Heading/HeadingRule.php new file mode 100644 index 00000000..44811cbd --- /dev/null +++ b/src/Markdown/Extensions/Heading/HeadingRule.php @@ -0,0 +1,27 @@ +<?php + +namespace App\Markdown\Extensions\Heading; + +use Tempest\Markdown\Parser; +use Tempest\Markdown\ProvidesFirstChar; +use Tempest\Markdown\Rule; +use Tempest\Markdown\Token; + +final class HeadingRule implements Rule, ProvidesFirstChar +{ + public string $firstChar = '#'; + + public function shouldParse(Parser $parser): bool + { + return $parser->comesNext('#', 1); + } + + public function parse(Parser $parser): Token + { + $buffer = $parser->consumeUntil(Parser::NEW_LINE); + + $level = strspn($buffer, '#'); + + return new HeadingToken(substr($buffer, $level) |> trim(...), $level); + } +} diff --git a/src/Markdown/Extensions/Heading/HeadingToken.php b/src/Markdown/Extensions/Heading/HeadingToken.php new file mode 100644 index 00000000..bd20cdf2 --- /dev/null +++ b/src/Markdown/Extensions/Heading/HeadingToken.php @@ -0,0 +1,50 @@ +<?php + +namespace App\Markdown\Extensions\Heading; + +use Tempest\Markdown\Parser; +use Tempest\Markdown\Rules\BoldAndItalicRule; +use Tempest\Markdown\Rules\BoldRule; +use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\ItalicRule; +use Tempest\Markdown\Rules\LinkRule; +use Tempest\Markdown\Rules\StrikethroughRule; +use Tempest\Markdown\Rules\TextRule; +use Tempest\Markdown\Token; + +final class HeadingToken implements Token +{ + public function __construct( + public string $content, + public int $level, + ) {} + + public function parse(Parser $parser): string + { + $tag = "h{$this->level}"; + + $slug = $this->content |> trim(...) |> strtolower(...) |> (fn (string $x) => str_replace(' ', '-', $x)); + + $id = " id=\"{$slug}\""; + + $content = $parser + ->forToken($this, [ + new BoldAndItalicRule(), + new BoldRule(), + new ItalicRule(), + new StrikethroughRule(), + new LinkRule(), + new CodeRule(), + new TextRule(), + ]) + ->parse($this->content); + + if ($this->level === 2 || $this->level === 3) { + $svg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 9h14M5 15h14M11 4L7 20M17 4l-4 16"/></svg>'; + + return "<{$tag}{$id}><a href=\"#{$slug}\" class=\"heading-permalink\"><span>{$svg}</span> {$content}</a></{$tag}>"; + } + + return "<{$tag}{$id}>{$content}</{$tag}>"; + } +} diff --git a/src/Markdown/Extensions/Link/LinkRule.php b/src/Markdown/Extensions/Link/LinkRule.php new file mode 100644 index 00000000..b3893e5e --- /dev/null +++ b/src/Markdown/Extensions/Link/LinkRule.php @@ -0,0 +1,59 @@ +<?php + +namespace App\Markdown\Extensions\Link; + +use Tempest\Markdown\Parser; +use Tempest\Markdown\ProvidesFirstChar; +use Tempest\Markdown\ProvidesStopChar; +use Tempest\Markdown\Rule; +use Tempest\Markdown\Token; + +final class LinkRule implements Rule, ProvidesFirstChar, ProvidesStopChar +{ + private(set) string $firstChar = '['; + private(set) string $stopChar = '['; + + public function shouldParse(Parser $parser): bool + { + return $parser->comesNext('[', 1); + } + + public function parse(Parser $parser): Token + { + $parser->consumeIncluding('['); + $content = $this->consumeContent($parser); + $parser->consumeIncluding(']'); + + $href = null; + + if ($parser->comesNext('(', 1)) { + $parser->consumeIncluding('('); + $href = $parser->consumeUntil(')'); + $parser->consumeIncluding(')'); + } + + return new LinkToken($content, $href); + } + + private function consumeContent(Parser $parser): string + { + $content = ''; + $bracketDepth = 0; + + while ($parser->current !== null) { + if ($parser->comesNext(']') && $bracketDepth === 0) { + break; + } + + if ($parser->comesNext('[')) { + $bracketDepth += 1; + } elseif ($parser->comesNext(']')) { + $bracketDepth -= 1; + } + + $content .= $parser->consume(); + } + + return $content; + } +} diff --git a/src/Markdown/Extensions/Link/LinkToken.php b/src/Markdown/Extensions/Link/LinkToken.php new file mode 100644 index 00000000..2addf8f8 --- /dev/null +++ b/src/Markdown/Extensions/Link/LinkToken.php @@ -0,0 +1,47 @@ +<?php + +namespace App\Markdown\Extensions\Link; + +use Tempest\Markdown\Parser; +use Tempest\Markdown\Rules\BoldAndItalicRule; +use Tempest\Markdown\Rules\BoldRule; +use Tempest\Markdown\Rules\ImageRule; +use Tempest\Markdown\Rules\ItalicRule; +use Tempest\Markdown\Rules\StrikethroughRule; +use Tempest\Markdown\Rules\TextRule; +use Tempest\Markdown\Token; +use function Tempest\Support\Str\replace; + +final class LinkToken implements Token +{ + public function __construct( + public string $content, + public ?string $href, + ) {} + + public function parse(Parser $parser): string + { + $content = $parser + ->forToken($this, [ + new BoldAndItalicRule(), + new BoldRule(), + new ItalicRule(), + new StrikethroughRule(), + new ImageRule(), + new TextRule(), + ]) + ->parse($this->content); + + $href = $this->href ?? ''; + $blank = ''; + + if (str_starts_with($href, '*')) { + $href = substr($href, 1); + $blank = ' target="_blank" rel="noopener noreferrer"'; + } + + $href= preg_replace('/\.md((?=[\/#?])|$)/', '', $href); + + return "<a href=\"{$href}\"{$blank}>{$content}</a>"; + } +} \ No newline at end of file diff --git a/src/Markdown/Extensions/Lists/ListRule.php b/src/Markdown/Extensions/Lists/ListRule.php new file mode 100644 index 00000000..f21d207e --- /dev/null +++ b/src/Markdown/Extensions/Lists/ListRule.php @@ -0,0 +1,57 @@ +<?php + +namespace App\Markdown\Extensions\Lists; + +use App\Web\Documentation\Version; +use Tempest\Markdown\Parser; +use Tempest\Markdown\ProvidesFirstChar; +use Tempest\Markdown\Rule; +use Tempest\Markdown\Token; +use Tempest\Markdown\Tokens\ListItem; + +final class ListRule implements Rule, ProvidesFirstChar +{ + public string $firstChar = '-'; + + public function __construct( + private readonly Version $version, + ) {} + + public function shouldParse(Parser $parser): bool + { + return $parser->comesNext('- ', 2); + } + + public function parse(Parser $parser): ?Token + { + $parser->consumeIncluding('- '); + $content = trim($parser->consumeUntil(Parser::NEW_LINE)); + $parser->consumeWhile(Parser::NEW_LINE); + + $childContent = ''; + $indent = strspn($parser->content, ' ', $parser->position); + + while ($indent >= 2 && $parser->current !== null) { + if (strspn($parser->content, ' ', $parser->position) < $indent) { + break; + } + + $parser->consume($indent); + $childContent .= $parser->consumeUntil(Parser::NEW_LINE) . PHP_EOL; + $parser->consumeWhile(Parser::NEW_LINE); + } + + $children = $childContent !== '' + ? $parser->withRules(new ListRule($this->version))->lex($childContent)[0] + : null; + + $item = new ListItem($content, $children); + + if ($parser->lastToken instanceof ListToken) { + $parser->lastToken->items[] = $item; + return null; + } + + return new ListToken($this->version, [$item]); + } +} \ No newline at end of file diff --git a/src/Markdown/Extensions/Lists/ListToken.php b/src/Markdown/Extensions/Lists/ListToken.php new file mode 100644 index 00000000..51253266 --- /dev/null +++ b/src/Markdown/Extensions/Lists/ListToken.php @@ -0,0 +1,50 @@ +<?php + +namespace App\Markdown\Extensions\Lists; + +use App\Markdown\Extensions\GitHubLink\GitHubLinkRule; +use App\Markdown\Extensions\Link\LinkRule; +use App\Web\Documentation\Version; +use Tempest\Markdown\Parser; +use Tempest\Markdown\Rules\BoldAndItalicRule; +use Tempest\Markdown\Rules\BoldRule; +use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\ImageRule; +use Tempest\Markdown\Rules\ItalicRule; +use Tempest\Markdown\Rules\TextRule; +use Tempest\Markdown\Token; + +final class ListToken implements Token +{ + public function __construct( + private readonly Version $version, + /** @var \Tempest\Markdown\Tokens\ListItem[] */ + public array $items = [], + ) {} + + public function parse(Parser $parser): string + { + $parser = $parser->forToken($this, [ + new GitHubLinkRule($this->version), + new BoldAndItalicRule(), + new BoldRule(), + new ItalicRule(), + new LinkRule(), + new ImageRule(), + new CodeRule(), + new TextRule(), + ]); + + $list = '<ul>'; + + foreach ($this->items as $item) { + $content = $parser->parse($item->content); + $children = $item->children?->parse($parser) ?? ''; + $list .= "<li>{$content}{$children}</li>"; + } + + $list .= '</ul>'; + + return $list; + } +} diff --git a/src/Markdown/Extensions/Paragraph/ParagraphRule.php b/src/Markdown/Extensions/Paragraph/ParagraphRule.php new file mode 100644 index 00000000..f8a4ed57 --- /dev/null +++ b/src/Markdown/Extensions/Paragraph/ParagraphRule.php @@ -0,0 +1,43 @@ +<?php + +namespace App\Markdown\Extensions\Paragraph; + +use App\Web\Documentation\Version; +use Tempest\Markdown\Parser; +use Tempest\Markdown\Rule; +use Tempest\Markdown\Token; + +final readonly class ParagraphRule implements Rule +{ + public function __construct( + private Version $version, + ) {} + + public function shouldParse(Parser $parser): bool + { + return true; + } + + public function parse(Parser $parser): Token + { + $content = ''; + + while ($parser->current !== null) { + $content .= $parser->consumeUntil(Parser::NEW_LINE); + + if ($parser->current === null) { + break; + } + + // A blank line (two consecutive newlines) ends the paragraph + if ($parser->comesNext("\n\n", 2) || $parser->comesNext("\r\n\r\n", 4) || $parser->comesNext("\n\r\n", 3) || $parser->comesNext("\r\n\n", 3)) { + break; + } + + // Single newline — consume it and continue to the next line + $content .= $parser->consumeWhile(Parser::NEW_LINE); + } + + return new ParagraphToken($this->version, $content); + } +} \ No newline at end of file diff --git a/src/Markdown/Extensions/Paragraph/ParagraphToken.php b/src/Markdown/Extensions/Paragraph/ParagraphToken.php new file mode 100644 index 00000000..d74d5325 --- /dev/null +++ b/src/Markdown/Extensions/Paragraph/ParagraphToken.php @@ -0,0 +1,43 @@ +<?php + +namespace App\Markdown\Extensions\Paragraph; + +use App\Markdown\Extensions\GitHubLink\GitHubLinkRule; +use App\Markdown\Extensions\Link\LinkRule; +use App\Web\Documentation\Version; +use Tempest\Markdown\Parser; +use Tempest\Markdown\Rules\BoldAndItalicRule; +use Tempest\Markdown\Rules\BoldRule; +use Tempest\Markdown\Rules\CodeRule; +use Tempest\Markdown\Rules\ImageRule; +use Tempest\Markdown\Rules\ItalicRule; +use Tempest\Markdown\Rules\StrikethroughRule; +use Tempest\Markdown\Rules\TextRule; +use Tempest\Markdown\Token; + +final readonly class ParagraphToken implements Token +{ + public function __construct( + public Version $version, + public string $content, + ) {} + + public function parse(Parser $parser): string + { + $parser = $parser->forToken($this, [ + new GitHubLinkRule($this->version), + new BoldAndItalicRule(), + new BoldRule(), + new ItalicRule(), + new StrikethroughRule(), + new LinkRule(), + new ImageRule(), + new CodeRule(), + new TextRule(), + ]); + + $content = $parser->parse($this->content); + + return "<p>{$content}</p>"; + } +} \ No newline at end of file diff --git a/src/Markdown/HeadingRenderer.php b/src/Markdown/HeadingRenderer.php deleted file mode 100644 index 72310dbd..00000000 --- a/src/Markdown/HeadingRenderer.php +++ /dev/null @@ -1,51 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown; - -use InvalidArgumentException; -use League\CommonMark\Extension\CommonMark\Node\Block\Heading; -use League\CommonMark\Node\Node; -use League\CommonMark\Renderer\ChildNodeRendererInterface; -use League\CommonMark\Renderer\NodeRendererInterface; -use League\CommonMark\Util\HtmlElement; -use Override; -use Stringable; -use Tempest\Support\Str\ImmutableString; - -final class HeadingRenderer implements NodeRendererInterface -{ - #[Override] - public function render(Node $node, ChildNodeRendererInterface $childRenderer): ?Stringable - { - if (! $node instanceof Heading) { - throw new InvalidArgumentException('Block must be instance of ' . Heading::class); - } - - $tag = 'h' . $node->getLevel(); - $attrs = $node->data->get('attributes'); - $slug = new ImmutableString($childRenderer->renderNodes($node->children())) - ->stripTags() - ->kebab() - ->toString(); - - $svg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 9h14M5 15h14M11 4L7 20M17 4l-4 16"/></svg>'; - - return new HtmlElement( - tagName: $tag, - attributes: [ - ...$attrs, - 'id' => $slug, - ], - contents: new HtmlElement( - tagName: 'a', - attributes: ['href' => '#' . $slug, 'class' => 'heading-permalink'], - contents: [ - new HtmlElement('span', contents: $svg), - $childRenderer->renderNodes($node->children()), - ], - ), - ); - } -} diff --git a/src/Markdown/LinkRenderer.php b/src/Markdown/LinkRenderer.php deleted file mode 100644 index b31e40ba..00000000 --- a/src/Markdown/LinkRenderer.php +++ /dev/null @@ -1,67 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown; - -use InvalidArgumentException; -use League\CommonMark\Extension\CommonMark\Node\Inline\Link; -use League\CommonMark\Extension\CommonMark\Renderer\Inline\LinkRenderer as InlineLinkRenderer; -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 League\Config\ConfigurationInterface; -use Override; -use Stringable; - -use function Tempest\Support\Regex\replace; - -final class LinkRenderer implements NodeRendererInterface, XmlNodeRendererInterface, ConfigurationAwareInterface -{ - private ConfigurationInterface $config; - - #[Override] - public function render(Node $node, ChildNodeRendererInterface $childRenderer): Stringable - { - if (! $node instanceof Link) { - throw new InvalidArgumentException('Node must be instance of ' . Link::class); - } - - // Replace .md at the end, before a / or a # - $node->setUrl( - replace($node->getUrl(), '/\.md((?=[\/#?])|$)/', ''), - ); - - $renderer = new InlineLinkRenderer(); - $renderer->setConfiguration($this->config); - - return $renderer->render($node, $childRenderer); - } - - #[Override] - public function setConfiguration(ConfigurationInterface $configuration): void - { - $this->config = $configuration; - } - - #[Override] - public function getXmlTagName(Node $node): string - { - return 'link'; - } - - #[Override] - public function getXmlAttributes(Node $node): array - { - if (! $node instanceof Link) { - throw new InvalidArgumentException('Node must be instance of ' . Link::class); - } - - return [ - 'destination' => $node->getUrl(), - 'title' => $node->getTitle() ?? '', - ]; - } -} diff --git a/src/Markdown/MarkdownInitializer.php b/src/Markdown/MarkdownInitializer.php index 72899b8f..a1c86418 100644 --- a/src/Markdown/MarkdownInitializer.php +++ b/src/Markdown/MarkdownInitializer.php @@ -1,66 +1,38 @@ <?php -declare(strict_types=1); - namespace App\Markdown; -use App\Markdown\Alerts\AlertExtension; -use App\Markdown\CodeBlocks\CodeBlockRenderer; -use App\Markdown\CodeBlocks\InlineCodeBlockRenderer; -use App\Markdown\CodeGroups\CodeGroupBlock; -use App\Markdown\CodeGroups\CodeGroupBlockRenderer; -use App\Markdown\CodeGroups\CodeGroupExtension; -use App\Markdown\Symbols\AttributeParser; -use App\Markdown\Symbols\FqcnParser; -use App\Markdown\Symbols\FunctionParser; -use App\Markdown\Symbols\HandleParser; -use App\Web\Documentation\Version; -use League\CommonMark\Environment\Environment; -use League\CommonMark\Extension\Attributes\AttributesExtension; -use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; -use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; -use League\CommonMark\Extension\CommonMark\Node\Block\Heading; -use League\CommonMark\Extension\CommonMark\Node\Inline\Code; -use League\CommonMark\Extension\CommonMark\Node\Inline\Link; -use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; -use League\CommonMark\Extension\Table\TableExtension; -use League\CommonMark\MarkdownConverter; -use Override; +use App\Markdown\Extensions\Heading\HeadingRule; +use App\Markdown\Extensions\Lists\ListRule; +use App\Markdown\Extensions\Paragraph\ParagraphRule; use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; use Tempest\Highlight\Highlighter; +use Tempest\Markdown\Markdown; +use Tempest\Markdown\Rules\ParagraphRule as TempestParagraphRule; +use Tempest\Markdown\Rules\ListRule as TempestListRule; +use Tempest\Markdown\Rules\HeadingRule as TempestHeadingRule; final readonly class MarkdownInitializer implements Initializer { - #[Override] #[Singleton] - public function initialize(Container $container): MarkdownConverter + public function initialize(Container $container): Markdown { - $environment = new Environment(); - $highlighter = $container->get(Highlighter::class, tag: 'project'); - - $codeBlockRenderer = new CodeBlockRenderer($highlighter); - $version = $container->get(Version::class); - - $environment - ->addExtension(new CommonMarkCoreExtension()) - ->addExtension(new FrontMatterExtension()) - ->addExtension(new AttributesExtension()) - ->addExtension(new AlertExtension()) - ->addExtension(new CodeGroupExtension()) - ->addExtension(new TableExtension()) - ->addInlineParser(new TempestPackageParser($version)) - ->addInlineParser(new FqcnParser($version)) - ->addInlineParser(new AttributeParser($version)) - ->addInlineParser(new FunctionParser($version)) - ->addInlineParser(new HandleParser()) - ->addRenderer(FencedCode::class, $codeBlockRenderer) - ->addRenderer(Code::class, new InlineCodeBlockRenderer($highlighter)) - ->addRenderer(Link::class, new LinkRenderer()) - ->addRenderer(Heading::class, new HeadingRenderer()) - ->addRenderer(CodeGroupBlock::class, new CodeGroupBlockRenderer($codeBlockRenderer)); - - return new MarkdownConverter($environment); + return new Markdown( + highlighter: $container->get(Highlighter::class, tag: 'project'), + ) + ->removeRules( + TempestParagraphRule::class, + TempestListRule::class, + TempestHeadingRule::class, + ) + ->prependRules( + $container->get(ListRule::class), + $container->get(HeadingRule::class), + ) + ->appendRules( + $container->get(ParagraphRule::class), + ); } -} +} \ No newline at end of file diff --git a/src/Markdown/ResolvesInfoWords.php b/src/Markdown/ResolvesInfoWords.php deleted file mode 100644 index 0e26de52..00000000 --- a/src/Markdown/ResolvesInfoWords.php +++ /dev/null @@ -1,28 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown; - -use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; - -trait ResolvesInfoWords -{ - private function getInfoWords(FencedCode $code): array - { - if ($code->getInfo() === null || $code->getInfo() === '') { - return []; - } - - $words = []; - $pattern = '/"([^"]*)"|(\S+)/'; - - \preg_match_all($pattern, $code->getInfo(), $matches, PREG_SET_ORDER); - - foreach ($matches as $match) { - $words[] = $match[1] !== '' ? $match[1] : $match[2]; - } - - return $words; - } -} diff --git a/src/Markdown/Symbols/AttributeParser.php b/src/Markdown/Symbols/AttributeParser.php deleted file mode 100644 index 492a0f55..00000000 --- a/src/Markdown/Symbols/AttributeParser.php +++ /dev/null @@ -1,64 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\Symbols; - -use App\Web\Documentation\Version; -use League\CommonMark\Extension\CommonMark\Node\Inline\Code; -use League\CommonMark\Extension\CommonMark\Node\Inline\Link; -use League\CommonMark\Parser\Inline\InlineParserInterface; -use League\CommonMark\Parser\Inline\InlineParserMatch; -use League\CommonMark\Parser\InlineParserContext; -use Override; - -use function Tempest\Support\str; -use function Tempest\Support\Str\to_kebab_case; - -final readonly class AttributeParser implements InlineParserInterface -{ - public function __construct( - private Version $version, - ) {} - - #[Override] - public function getMatchDefinition(): InlineParserMatch - { - return InlineParserMatch::regex("{(b)?`#\[((?:\\\{1,2}\w+|\w+\\\{1,2})(?:\w+\\\{0,2})+)\]`}"); - } - - #[Override] - public function parse(InlineParserContext $inlineContext): bool - { - $cursor = $inlineContext->getCursor(); - $previousChar = $cursor->peek(-1); - - if ($previousChar !== null && $previousChar !== ' ') { - return false; - } - - $cursor->advanceBy($inlineContext->getFullMatchLength()); - - [$flag, $fqcn] = $inlineContext->getSubMatches(); - $url = str($fqcn) - ->stripStart(['\\Tempest\\', 'Tempest\\']) - ->replaceRegex("/^(\w+)/", static fn (array $matches) => sprintf('packages/%s/src', to_kebab_case($matches[0]))) - ->replaceEvery(['date-time' => 'datetime']) - ->replace('\\', '/') - ->prepend('https://github.com/tempestphp/tempest-framework/blob/' . $this->version->getBranch() . '/') - ->append('.php') - ->toString(); - - $attribute = str($fqcn) - ->stripStart('\\') - ->when($flag === 'b', static fn ($s) => $s->classBasename()) - ->wrap(before: '#[', after: ']') - ->toString(); - - $link = new Link($url); - $link->appendChild(new Code($attribute)); - $inlineContext->getContainer()->appendChild($link); - - return true; - } -} diff --git a/src/Markdown/Symbols/FqcnParser.php b/src/Markdown/Symbols/FqcnParser.php deleted file mode 100644 index 68828c29..00000000 --- a/src/Markdown/Symbols/FqcnParser.php +++ /dev/null @@ -1,61 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\Symbols; - -use App\Web\Documentation\Version; -use League\CommonMark\Extension\CommonMark\Node\Inline\Code; -use League\CommonMark\Extension\CommonMark\Node\Inline\Link; -use League\CommonMark\Parser\Inline\InlineParserInterface; -use League\CommonMark\Parser\Inline\InlineParserMatch; -use League\CommonMark\Parser\InlineParserContext; -use Override; -use Tempest\Support\Str; - -use function Tempest\Support\str; -use function Tempest\Support\Str\class_basename; -use function Tempest\Support\Str\strip_start; -use function Tempest\Support\Str\to_kebab_case; - -final readonly class FqcnParser implements InlineParserInterface -{ - public function __construct( - private Version $version, - ) {} - - #[Override] - public function getMatchDefinition(): InlineParserMatch - { - return InlineParserMatch::regex("{(b)?`((?:\\\{1,2}\w+|\w+\\\{1,2})(?:\w+\\\{0,2})+)`}"); - } - - #[Override] - public function parse(InlineParserContext $inlineContext): bool - { - $cursor = $inlineContext->getCursor(); - $previousChar = $cursor->peek(-1); - - if ($previousChar !== null && $previousChar !== ' ') { - return false; - } - - $cursor->advanceBy($inlineContext->getFullMatchLength()); - - [$flag, $fqcn] = $inlineContext->getSubMatches(); - $url = str($fqcn) - ->stripStart(['\\Tempest\\', 'Tempest\\']) - ->replaceRegex("/^(\w+)/", static fn (array $matches) => sprintf('packages/%s/src', to_kebab_case($matches[0]))) - ->replaceEvery(['date-time' => 'datetime']) - ->replace('\\', '/') - ->prepend('https://github.com/tempestphp/tempest-framework/blob/' . $this->version->getBranch() . '/') - ->append('.php') - ->toString(); - - $link = new Link($url); - $link->appendChild(new Code($flag === 'b' ? class_basename($fqcn) : strip_start($fqcn, '\\'))); - $inlineContext->getContainer()->appendChild($link); - - return true; - } -} diff --git a/src/Markdown/Symbols/FunctionParser.php b/src/Markdown/Symbols/FunctionParser.php deleted file mode 100644 index bf33216b..00000000 --- a/src/Markdown/Symbols/FunctionParser.php +++ /dev/null @@ -1,80 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\Symbols; - -use App\Web\Documentation\Version; -use League\CommonMark\Extension\CommonMark\Node\Inline\Code; -use League\CommonMark\Extension\CommonMark\Node\Inline\Link; -use League\CommonMark\Parser\Inline\InlineParserInterface; -use League\CommonMark\Parser\Inline\InlineParserMatch; -use League\CommonMark\Parser\InlineParserContext; -use Override; -use ReflectionFunction; -use Tempest\Support\Str\ImmutableString; -use Throwable; - -use function Tempest\Support\str; - -final readonly class FunctionParser implements InlineParserInterface -{ - public function __construct( - private Version $version, - ) {} - - #[Override] - public function getMatchDefinition(): InlineParserMatch - { - return InlineParserMatch::regex("{(b)?`((?:\\\{1,2}\w+|\w+\\\{1,2})(?:\w+\\\{0,2})+)\(\)`}"); - } - - #[Override] - public function parse(InlineParserContext $inlineContext): bool - { - $cursor = $inlineContext->getCursor(); - $previousChar = $cursor->peek(-1); - - if ($previousChar !== null && $previousChar !== ' ') { - return false; - } - - $cursor->advanceBy($inlineContext->getFullMatchLength()); - - [$flag, $fqf] = $inlineContext->getSubMatches(); - - $reflection = $this->createReflectionFromFqf($fqf); - $function = str($fqf) - ->stripStart('\\') - ->when($flag === 'b', static fn (ImmutableString $s) => $s->afterLast('\\')) - ->append('()') - ->toString(); - - if (! $reflection) { - $inlineContext->getContainer()->appendChild(new Code($function)); - - return true; - } - - $url = str($reflection->getFileName()) - ->afterLast('tempest/framework/') - ->prepend('https://github.com/tempestphp/tempest-framework/blob/' . $this->version->getBranch() . '/') - ->append("#L{$reflection->getStartLine()}-L{$reflection->getEndLine()}") - ->toString(); - - $link = new Link($url); - $link->appendChild(new Code($function)); - $inlineContext->getContainer()->appendChild($link); - - return true; - } - - private function createReflectionFromFqf(string $fqf): ?ReflectionFunction - { - try { - return new ReflectionFunction($fqf); - } catch (Throwable) { - return null; - } - } -} diff --git a/src/Markdown/Symbols/HandleParser.php b/src/Markdown/Symbols/HandleParser.php deleted file mode 100644 index 2bb37bd8..00000000 --- a/src/Markdown/Symbols/HandleParser.php +++ /dev/null @@ -1,51 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace App\Markdown\Symbols; - -use League\CommonMark\Extension\CommonMark\Node\Inline\Link; -use League\CommonMark\Parser\Inline\InlineParserInterface; -use League\CommonMark\Parser\Inline\InlineParserMatch; -use League\CommonMark\Parser\InlineParserContext; -use Override; -use RuntimeException; - -final readonly class HandleParser implements InlineParserInterface -{ - #[Override] - public function getMatchDefinition(): InlineParserMatch - { - return InlineParserMatch::regex('{(twitter|x|bluesky|bsky|gh|github):(.+?)(?:,(.+?))?}'); - } - - #[Override] - public function parse(InlineParserContext $inlineContext): bool - { - $cursor = $inlineContext->getCursor(); - $previousChar = $cursor->peek(-1); - - if ($previousChar !== null && $previousChar !== ' ') { - return false; - } - - $cursor->advanceBy($inlineContext->getFullMatchLength()); - - [$platform, $handle, $text] = $inlineContext->getSubMatches() + [null, null, null]; - - $url = match ($platform) { - 'bluesky', 'bsky' => "https://bsky.app/profile/{$handle}", - 'gh', 'github' => "https://github.com/{$handle}", - 'x', 'twitter' => "https://x.com/{$handle}", - default => throw new RuntimeException("Unknown platform: {$platform}"), - }; - - $inlineContext - ->getContainer() - ->appendChild( - new Link($url, label: $text ?? '@' . $handle), - ); - - return true; - } -} diff --git a/src/Web/Blog/BlogIndexer.php b/src/Web/Blog/BlogIndexer.php index 56d22ce0..3727a061 100644 --- a/src/Web/Blog/BlogIndexer.php +++ b/src/Web/Blog/BlogIndexer.php @@ -7,10 +7,8 @@ use App\Web\CommandPalette\Command; use App\Web\CommandPalette\Indexer; use App\Web\CommandPalette\Type; -use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; -use League\CommonMark\MarkdownConverter; use Override; -use RuntimeException; +use Tempest\Markdown\Markdown; use Tempest\Support\Arr\ImmutableArray; use function Tempest\Router\uri; @@ -21,7 +19,7 @@ final readonly class BlogIndexer implements Indexer { public function __construct( - private MarkdownConverter $markdown, + private Markdown $markdown, ) {} #[Override] @@ -29,14 +27,10 @@ public function index(): ImmutableArray { return arr(glob(__DIR__ . '/articles/*.md')) ->map(function (string $path) { - $markdown = $this->markdown->convert(file_get_contents($path)); + $parsed = $this->markdown->parse(file_get_contents($path)); preg_match('/\d+-\d+-\d+-(?<slug>.*)\.md/', $path, $matches); - if (! $markdown instanceof RenderedContentWithFrontMatter) { - throw new RuntimeException(sprintf('Blog entry [%s] is missing a frontmatter.', $path)); - } - - $frontmatter = $markdown->getFrontMatter(); + $frontmatter = $parsed->frontmatter; $title = get_by_key($frontmatter, 'title'); $author = get_by_key($frontmatter, 'author'); $description = get_by_key($frontmatter, 'description'); @@ -44,14 +38,14 @@ public function index(): ImmutableArray $tags = get_by_key($frontmatter, 'tag'); return new Command( - type: Type::URI, title: $title, - uri: uri([BlogController::class, 'show'], slug: $matches['slug']), + type: Type::URI, hierarchy: [ 'Blog', Author::tryFrom($author)?->getName() ?? 'Tempest', $title, ], + uri: uri([BlogController::class, 'show'], slug: $matches['slug']), fields: [ $author, $description, diff --git a/src/Web/Blog/BlogRepository.php b/src/Web/Blog/BlogRepository.php index b3e32c88..0e382d1a 100644 --- a/src/Web/Blog/BlogRepository.php +++ b/src/Web/Blog/BlogRepository.php @@ -5,10 +5,9 @@ namespace App\Web\Blog; use DateTimeImmutable; -use Exception; -use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; -use League\CommonMark\MarkdownConverter; use Spatie\YamlFrontMatter\YamlFrontMatter; +use Tempest\Markdown\Markdown; +use Tempest\Markdown\ParsedMarkdown; use Tempest\Support\Arr\ImmutableArray; use function Tempest\Mapper\map; @@ -17,7 +16,7 @@ final readonly class BlogRepository { public function __construct( - private MarkdownConverter $markdown, + private Markdown $markdown, ) {} /** @@ -48,7 +47,7 @@ public function all(bool $loadContent = false): ImmutableArray } if ($loadContent) { - $data['content'] = $this->parseContent($path)->getContent(); + $data['content'] = $this->parseContent($path)->html; } return $data; @@ -69,9 +68,9 @@ public function find(string $slug): ?BlogPost $data = [ 'slug' => $slug, - 'content' => $content->getContent(), + 'content' => $content->html, 'createdAt' => $this->parseDate($path), - ...$content->getFrontMatter(), + ...$content->frontmatter, ]; if (isset($data['tag'])) { @@ -85,7 +84,7 @@ public function find(string $slug): ?BlogPost return map($data)->to(BlogPost::class); } - private function parseContent(string $path): ?RenderedContentWithFrontMatter + private function parseContent(string $path): ?ParsedMarkdown { $content = @file_get_contents($path); // @mago-expect lint:no-error-control-operator @@ -93,13 +92,7 @@ private function parseContent(string $path): ?RenderedContentWithFrontMatter return null; } - $parsed = $this->markdown->convert($content); - - if (! $parsed instanceof RenderedContentWithFrontMatter) { - throw new Exception("Missing frontmatter or content in {$path}"); - } - - return $parsed; + return $this->markdown->parse($content); } private function parseDate(string $path): DateTimeImmutable diff --git a/src/Web/Challenges/parsing-100m-lines.html b/src/Web/Challenges/parsing-100m-lines.html index 2c3bf7a4..9be9df4e 100644 --- a/src/Web/Challenges/parsing-100m-lines.html +++ b/src/Web/Challenges/parsing-100m-lines.html @@ -957,14 +957,14 @@ <body> <div class="loading-overlay" id="loading-overlay"> - <img src="https://tempestphp.com/img/tempest-logo.svg" alt="Loading..."> + <img src="/img/tempest-logo.png" alt="Loading..."> </div> <header class="site-header"> <div class="header-inner"> <div class="header-left"> <a href="https://tempestphp.com" class="header-logo"> - <img src="https://tempestphp.com/img/tempest-logo.svg" alt="Tempest logo"> + <img src="/img/tempest-logo.png" alt="Tempest logo"> <span>Tempest</span> </a> <span class="header-badge">100M Challenge</span> diff --git a/src/Web/Documentation/ChapterRepository.php b/src/Web/Documentation/ChapterRepository.php index ca7498f1..c1e0e714 100644 --- a/src/Web/Documentation/ChapterRepository.php +++ b/src/Web/Documentation/ChapterRepository.php @@ -5,10 +5,9 @@ namespace App\Web\Documentation; use App\Support\HasMemoization; -use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; -use League\CommonMark\MarkdownConverter; use RuntimeException; use Spatie\YamlFrontMatter\YamlFrontMatter; +use Tempest\Markdown\Markdown; use Tempest\Support\Arr\ImmutableArray; use function Tempest\root_path; @@ -23,7 +22,7 @@ final class ChapterRepository use HasMemoization; public function __construct( - private readonly MarkdownConverter $markdown, + private readonly Markdown $markdown, ) {} public function find(Version $version, string $category, string $slug): ?Chapter @@ -46,13 +45,9 @@ public function find(Version $version, string $category, string $slug): ?Chapter } $raw = file_get_contents($path); - $markdown = $this->markdown->convert($raw); + $markdown = $this->markdown->parse($raw); - if (! $markdown instanceof RenderedContentWithFrontMatter) { - throw new RuntimeException(sprintf('Documentation entry [%s] is missing a frontmatter.', $path)); - } - - $frontmatter = $markdown->getFrontMatter(); + $frontmatter = $markdown->frontmatter; $title = get_by_key($frontmatter, 'title'); $description = get_by_key($frontmatter, 'description'); $hidden = get_by_key($frontmatter, 'hidden'); @@ -61,7 +56,7 @@ public function find(Version $version, string $category, string $slug): ?Chapter version: $version, category: $category, slug: $slug, - body: $markdown->getContent(), + body: $markdown->html, raw: $raw, title: $title, path: to_relative_path(root_path(), $path), diff --git a/src/Web/Documentation/DocumentationIndexer.php b/src/Web/Documentation/DocumentationIndexer.php index 8a26e506..70773857 100644 --- a/src/Web/Documentation/DocumentationIndexer.php +++ b/src/Web/Documentation/DocumentationIndexer.php @@ -7,21 +7,16 @@ use App\Web\CommandPalette\Command; use App\Web\CommandPalette\Indexer; use App\Web\CommandPalette\Type; -use League\CommonMark\Extension\CommonMark\Node\Block\Heading; -use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; -use League\CommonMark\MarkdownConverter; -use League\CommonMark\Node\Inline\Text; -use League\CommonMark\Node\Query; use Override; -use RuntimeException; +use Tempest\Markdown\Markdown; use Tempest\Support\Arr\ImmutableArray; use Tempest\Support\Str\ImmutableString; +use function Tempest\Support\str; use function Tempest\Router\uri; use function Tempest\Support\arr; use function Tempest\Support\Arr\get_by_key; use function Tempest\Support\Arr\wrap; -use function Tempest\Support\Str\to_kebab_case; use function Tempest\Support\Str\to_sentence_case; /** @@ -30,7 +25,7 @@ final readonly class DocumentationIndexer implements Indexer { public function __construct( - private MarkdownConverter $markdown, + private Markdown $markdown, ) {} /** @@ -43,55 +38,48 @@ public function index(): ImmutableArray return arr(glob(__DIR__ . "/content/{$version->value}/*/*.md")) ->flatMap(function (string $path) use ($version) { - $markdown = $this->markdown->convert(file_get_contents($path)); - - if (! $markdown instanceof RenderedContentWithFrontMatter) { - throw new RuntimeException(sprintf('Documentation entry [%s] is missing a frontmatter.', $path)); - } + $markdown = $this->markdown->parse(file_get_contents($path)); $path = new ImmutableString($path); $category = $path->beforeLast('/')->afterLast('/')->replaceRegex('/\d+-/', ''); $chapter = $path->basename('.md')->replaceRegex('/\d+-/', ''); - $title = get_by_key($markdown->getFrontMatter(), 'title'); - $keywords = get_by_key($markdown->getFrontMatter(), 'keywords'); + $title = get_by_key($markdown->frontmatter, 'title'); + $keywords = get_by_key($markdown->frontmatter, 'keywords'); - if (get_by_key($markdown->getFrontMatter(), 'hidden') === true) { + if (get_by_key($markdown->frontmatter, 'hidden') === true) { return []; } $main = new Command( - type: Type::URI, title: $title, - uri: uri(DocumentationController::class, version: $version, category: $category, slug: $chapter), + type: Type::URI, hierarchy: [ 'Documentation', to_sentence_case($category), $title, ], + uri: uri(DocumentationController::class, version: $version, category: $category, slug: $chapter), fields: [ ...wrap($keywords), ], ); - /** @var Heading[] */ - $matchingNodes = new Query() - ->where(Query::type(Heading::class)) - ->findAll($markdown->getDocument()); + preg_match_all('/<h2.*?<\/h2>/', $markdown->html, $matches); + + $indices = arr($matches[0] ?? []) + ->map(static function (string $h2) use ($main) { + $title = str($h2)->afterLast('</span>')->beforeLast('</a')->trim(); - $indices = arr(iterator_to_array($matchingNodes)) - ->map(static function (Heading $heading) use ($main) { - /** @var Text */ - $text = $heading->firstChild(); - $slug = to_kebab_case($text->getLiteral()); + $slug = $title->kebab(); return new Command( + title: $title->toString(), type: Type::URI, - title: $text->getLiteral(), - uri: $main->uri . '#' . $slug, hierarchy: [ ...$main->hierarchy, - $text->getLiteral(), + $slug->toString(), ], + uri: $main->uri . '#' . $slug, ); }) ->filter(); diff --git a/src/Web/Homepage/HomeController.php b/src/Web/Homepage/HomeController.php index 800da34f..8f4ba84e 100644 --- a/src/Web/Homepage/HomeController.php +++ b/src/Web/Homepage/HomeController.php @@ -4,20 +4,19 @@ namespace App\Web\Homepage; -use League\CommonMark\MarkdownConverter; use Tempest\Http\Responses\Redirect; +use Tempest\Markdown\Markdown; use Tempest\Router\Get; use Tempest\Router\StaticPage; use Tempest\View\View; use function Tempest\Support\Arr\map_with_keys; use function Tempest\Support\Str\strip_end; -use function Tempest\View\view; final readonly class HomeController { public function __construct( - private MarkdownConverter $markdown, + private Markdown $markdown, ) {} #[StaticPage] @@ -26,7 +25,7 @@ public function __invoke(): View { $codeBlocks = map_with_keys( glob(__DIR__ . '/codeblocks/*.md'), - fn (string $path) => yield strip_end(basename($path), '.md') => $this->markdown->convert(file_get_contents($path)), + fn (string $path) => yield strip_end(basename($path), '.md') => $this->markdown->parse(file_get_contents($path))->html, ); return \Tempest\View\view('./home.view.php', codeBlocks: $codeBlocks); diff --git a/src/Web/Homepage/codeblocks/static-pages.md b/src/Web/Homepage/codeblocks/static-pages.md index f558cd99..bd3e8e13 100644 --- a/src/Web/Homepage/codeblocks/static-pages.md +++ b/src/Web/Homepage/codeblocks/static-pages.md @@ -1,4 +1,4 @@ -```console ">_ ./tempest static:generate" +```console >_ ./tempest static:generate /framework/01-getting-started <dim>..</dim> <em>/public/framework/01-getting-started/index.html</em> /framework/02-the-container <dim>......</dim> <em>/public/framework/02-the-container/index.html</em> /framework/03-controllers <dim>..........</dim> <em>/public/framework/03-controllers/index.html</em> diff --git a/src/Web/Homepage/x-home-section.view.php b/src/Web/Homepage/x-home-section.view.php index 8862ec00..e55fa97b 100644 --- a/src/Web/Homepage/x-home-section.view.php +++ b/src/Web/Homepage/x-home-section.view.php @@ -6,7 +6,7 @@ <span class="flex flex-col tracking-tighter text-xl md:text-4xl xl:text-4xl leading-tight text-(--ui-text-toned)"> {{ $heading }} </span> - <p :foreach="$paragraphs as $paragraph" class="mt-2 md:mt-4 xl:mt-6 text-xl xl:text-2xl text-(--ui-text-muted) leading-snug"> + <p :foreach="$paragraphs as $paragraph" class="mt-2 md:mt-4 xl:mt-6 text-xl xl:text-xl text-(--ui-text-muted) leading-snug"> {{ $paragraph }} </p> </div> @@ -22,7 +22,7 @@ </div> <!-- Right --> <div class="flex flex-col gap-2 p-3 rounded-xl text-sm tracking-normal home"> - <div :foreach="$snippets as $snippet"> + <div :foreach="$snippets as $snippet" class="home-code-block"> {!! $this->codeBlocks[$snippet] !!} </div> </div> diff --git a/src/Web/assets/main.entrypoint.css b/src/Web/assets/main.entrypoint.css index 621378ab..12d9eaf0 100644 --- a/src/Web/assets/main.entrypoint.css +++ b/src/Web/assets/main.entrypoint.css @@ -277,20 +277,36 @@ pre { |-------------------------------------------------------------------------- */ -.home .code-block { - @apply flex flex-col gap-y-0 p-0.5 rounded-xl border border-(--ui-border) - bg-(--ui-bg-elevated)/50 overflow-hidden; +.home .home-code-block { + & + .home-code-block { + margin-top: 1em; + } - &.named-code-block { - .code-block-name { - @apply mb-1.5 mt-1 px-2 font-mono text-(--ui-text-dimmed) text-sm; + & .code-title { + margin-bottom: 0; + margin-left: 1.1em; + font-size: 1em; + color: var(--ui-text-muted); + background: var(--code-border); + padding: 0em 0.5em; + display: flex; + float: left; + border-radius: 0.2em 0.2em 0 0; + + & + pre { + margin-top: 0; } } - pre { - @apply border border-(--ui-border) rounded-[0.6rem] p-4 my-0 - bg-(--ui-bg-elevated)/50 overflow-x-auto; - tab-size: 2; + & pre { + background-color: #ffffffdd; + border-radius: 3px; + display: block; + clear:both; + padding: 1em 2ch; + box-shadow: 0 0 15px 5px var(--ui-border); + overflow-x: auto; + line-height: 1.8em; } } diff --git a/src/Web/assets/typography.css b/src/Web/assets/typography.css index 67d793d2..f431b0a0 100644 --- a/src/Web/assets/typography.css +++ b/src/Web/assets/typography.css @@ -79,39 +79,19 @@ } } - .code-group { - @apply my-[1.71em] flex flex-col gap-y-0 rounded-lg border - border-(--ui-border) bg-(--ui-bg-elevated)/20; - - .code-block-name { - @apply hidden; - } - - .code-group-tabs { - @apply flex gap-x-1 px-2 py-1 pt-1.5 overflow-x-auto grow; - - .code-group-tab { - @apply px-3 whitespace-nowrap py-1.5 font-mono text-sm text-left - text-(--ui-text-dimmed) rounded-md hover:text-(--ui-text-highlighted) - transition-colors cursor-pointer; - - &.active { - @apply text-(--ui-text-highlighted) border-(--ui-border) - bg-(--ui-bg-elevated) border-b-transparent; - } - } - } - - .code-group-panel { - @apply hidden; - - &.active { - @apply block; - } - - .code-block { - @apply my-0 border-0 rounded-none bg-transparent; - } + .code-title { + margin-bottom: 0; + margin-left: 1.1em; + font-size: 0.8em; + color: var(--ui-text-muted); + background: var(--code-border); + padding: 0em 0.5em; + display: flex; + float: left; + border-radius: 0.2em 0.2em 0 0; + + & + pre { + margin-top: 0; } } diff --git a/src/Web/x-header.view.php b/src/Web/x-header.view.php index 0a8819ec..58450047 100644 --- a/src/Web/x-header.view.php +++ b/src/Web/x-header.view.php @@ -22,7 +22,7 @@ class="group transition-[top,border] z-[1] fixed top-4 data-[scrolling]:top-0 fl <a href="/" class="flex items-center gap-4"> <!-- Logo --> <div class="size-8"> - <img src="/img/tempest-logo.svg" alt="Tempest logo" class="size-full"/> + <img src="/img/tempest-logo.png" alt="Tempest logo" class="size-full"/> </div> <span class="hidden lg:inline font-medium">Tempest</span> </a> diff --git a/src/functions.php b/src/functions.php index 27e05ff7..c27d629c 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,15 +2,6 @@ declare(strict_types=1); -use App\Markdown\MarkdownPost; -use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; -use League\CommonMark\MarkdownConverter; -use Tempest\View\View; - -use function Tempest\Container\get; -use function Tempest\Mapper\map; -use function Tempest\View\view; - function recursive_search(string $folder, string $pattern): Generator { $directory = new RecursiveDirectoryIterator($folder); @@ -20,23 +11,4 @@ function recursive_search(string $folder, string $pattern): Generator foreach ($files as $file) { yield $file->getPathName(); } -} - -function markdown(string $file): View -{ - $markdown = get(MarkdownConverter::class); - - $content = $markdown->convert(file_get_contents($file)); - - $data = [ - 'content' => $content, - ]; - - if ($content instanceof RenderedContentWithFrontMatter) { - $data = [...$data, ...$content->getFrontMatter()]; - } - - $post = map($data)->to(MarkdownPost::class); - - return \Tempest\View\view(__DIR__ . '/Web/markdown.view.php', post: $post); -} +} \ No newline at end of file diff --git a/tests/Markdown/Extensions/GitHubLink/GitHubLinkRuleTest.php b/tests/Markdown/Extensions/GitHubLink/GitHubLinkRuleTest.php new file mode 100644 index 00000000..dce03305 --- /dev/null +++ b/tests/Markdown/Extensions/GitHubLink/GitHubLinkRuleTest.php @@ -0,0 +1,53 @@ +<?php + +namespace Tests\Markdown\Extensions\GitHubLink; + +use App\Markdown\Extensions\Paragraph\ParagraphToken; +use App\Web\Documentation\Version; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Tempest\Markdown\Parser; + +class GitHubLinkRuleTest extends TestCase +{ + private Parser $parser; + + #[Before] + public function setupParser(): void + { + $this->parser = new Parser(); + } + + #[Test] + public function test_parse(): void + { + $token = new ParagraphToken( + Version::VERSION_3, + 'These attributes implement the {b`Tempest\Router\Route`} interface, allowing custom route attributes to be created', + ); + + $parsed = $token->parse($this->parser); + + $this->assertStringContainsString( + 'https://github.com/tempestphp/tempest-framework/blob/3.x/packages/router/src/Route.php', + $parsed, + ); + } + + #[Test] + public function test_parse_without_b(): void + { + $token = new ParagraphToken( + Version::VERSION_3, + 'These attributes implement the {`Tempest\Router\Route`} interface, allowing custom route attributes to be created', + ); + + $parsed = $token->parse($this->parser); + + $this->assertStringContainsString( + 'https://github.com/tempestphp/tempest-framework/blob/3.x/packages/router/src/Route.php', + $parsed, + ); + } +} diff --git a/tests/Markdown/Extensions/GitHubLink/GitHubLinkTokenTest.php b/tests/Markdown/Extensions/GitHubLink/GitHubLinkTokenTest.php new file mode 100644 index 00000000..cbb18d8c --- /dev/null +++ b/tests/Markdown/Extensions/GitHubLink/GitHubLinkTokenTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Tests\Markdown\Extensions\GitHubLink; + +use App\Markdown\Extensions\GitHubLink\GitHubLinkToken; +use App\Web\Documentation\Version; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Tempest\Markdown\Parser; + +class GitHubLinkTokenTest extends TestCase +{ + #[Test] + public function test_with_class(): void + { + $token = new GitHubLinkToken(Version::VERSION_3, 'Tempest\Router\Route'); + + $html = $token->parse(new Parser()); + + $this->assertSame( + '<a href="https://github.com/tempestphp/tempest-framework/blob/3.x/packages/router/src/Route.php"><code><span class="hl-type">Route</span></code></a>', + $html, + ); + } + + #[Test] + public function test_with_class_and_leading_slash(): void + { + $token = new GitHubLinkToken(Version::VERSION_3, '\Tempest\Router\Route'); + + $html = $token->parse(new Parser()); + + $this->assertSame( + '<a href="https://github.com/tempestphp/tempest-framework/blob/3.x/packages/router/src/Route.php"><code><span class="hl-type">Route</span></code></a>', + $html, + ); + } + + #[Test] + public function test_with_attribute_and_leading_slash(): void + { + $token = new GitHubLinkToken(Version::VERSION_3, '#[Tempest\Discovery\SkipDiscovery]'); + + $html = $token->parse(new Parser()); + + $this->assertSame( + '<a href="https://github.com/tempestphp/tempest-framework/blob/3.x/packages/discovery/src/SkipDiscovery.php"><code>#[<span class="hl-type">SkipDiscovery</span>]</code></a>', + $html, + ); + } +}