From b085a32fa2e99815f24dd213cdf6a9e27e39d27a Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sun, 7 Jun 2026 22:59:39 +0300 Subject: [PATCH 1/2] Add configurable output minification --- benchmarks/OutputMinifierBench.php | 40 +++++++++ docs/configuration.md | 14 +++ roadmap.md | 1 + src/Build/AuthorPageWriter.php | 6 +- src/Build/CollectionListingWriter.php | 2 +- src/Build/DateArchiveWriter.php | 2 +- src/Build/EntryRenderer.php | 8 +- src/Build/NotFoundPageWriter.php | 2 +- src/Build/OutputMinifier.php | 87 +++++++++++++++++++ src/Build/PageTemplateRenderer.php | 5 +- src/Build/RedirectPageWriter.php | 4 + src/Build/TaxonomyPageWriter.php | 2 +- src/Content/Model/SiteConfig.php | 1 + src/Content/Parser/SiteConfigParser.php | 1 + tests/Unit/Build/EntryRendererTest.php | 2 + tests/Unit/Build/OutputMinifierTest.php | 65 ++++++++++++++ tests/Unit/Build/PageTemplateRendererTest.php | 31 +++++++ .../Content/Parser/SiteConfigParserTest.php | 14 +++ 18 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 benchmarks/OutputMinifierBench.php create mode 100644 src/Build/OutputMinifier.php create mode 100644 tests/Unit/Build/OutputMinifierTest.php diff --git a/benchmarks/OutputMinifierBench.php b/benchmarks/OutputMinifierBench.php new file mode 100644 index 0000000..6aa9893 --- /dev/null +++ b/benchmarks/OutputMinifierBench.php @@ -0,0 +1,40 @@ + +

Generated output

+

YiiPress keeps generated pages compact while preserving code.

+
Line 1
+                    Line 2
+ + + HTML; + + $this->html = str_repeat($block, 100); + } + + #[Revs(100)] + #[Iterations(3)] + #[Warmup(1)] + public function benchHtmlMinification(): void + { + OutputMinifier::html($this->html); + } +} diff --git a/docs/configuration.md b/docs/configuration.md index db9debe..9d44356 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,6 +44,8 @@ params: assets: fingerprint: true +minify: true + last_updated: true edit_page: https://github.com/example/mysite/edit/main/content/{path} report_issue: https://github.com/example/mysite/issues/new?title=Docs:%20{title}&body={url} @@ -72,6 +74,7 @@ editor: code - **toc** — generate a table of contents from headings (default: `true`); set to `false` to disable globally. When enabled, heading tags receive `id` attributes and a `$toc` variable is passed to templates - **search** — opt-in client-side search (see below) - **related** — opt-in related content suggestions (see below) +- **minify** — minify generated HTML output (default: `true`); set to `false` to keep rendered template whitespace - **last_updated** — set to `true` to show each entry source file's last modification time below its content (default: `false`) - **edit_page** — URL template for an optional "Edit this page" link below entry content (see below) - **report_issue** — URL template for an optional "Report an issue" link below entry content (see below) @@ -211,6 +214,17 @@ asset references in rendered HTML are rewritten during build so custom themes co Root-relative local asset references are treated as YiiPress site-root paths and emitted relative to the current output page, so they remain valid when `base_url` contains a deployment path. +### Output minification + +Generated HTML pages are minified by default: + +```yaml +minify: true +``` + +Set `minify: false` to keep template indentation and line breaks in generated `*.html` files. +Whitespace inside `pre`, `textarea`, `script`, and `style` elements is preserved either way. + ### Editor During `yiipress serve`, HTML pages get a fixed bottom-right **Edit** button. Clicking it asks the preview server to open the markdown source file that produced the current page. diff --git a/roadmap.md b/roadmap.md index 19282d6..f36c641 100644 --- a/roadmap.md +++ b/roadmap.md @@ -76,6 +76,7 @@ - [x] Static file copying (fonts, downloads, PDFs from source to output) - [x] Root-relative asset URLs stay valid when deploying under a subdirectory - [x] Nightly Linux binary published for GitHub Actions preview builds +- [x] Configurable generated HTML output minification ## Priority 6: SEO and web standards diff --git a/src/Build/AuthorPageWriter.php b/src/Build/AuthorPageWriter.php index 4b1519d..97d21fd 100644 --- a/src/Build/AuthorPageWriter.php +++ b/src/Build/AuthorPageWriter.php @@ -40,7 +40,7 @@ public function write( return 0; } - $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest); + $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify); $this->writeIndex($siteConfig, $authors, $outputDir, $navigation, $noWrite); $pageCount = 1; @@ -65,7 +65,7 @@ public function writeIndex( bool $noWrite = false, ): void { $this->writeIndexPage( - new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest), + new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify), $siteConfig, $authors, $outputDir, @@ -89,7 +89,7 @@ public function writeAuthor( bool $noWrite = false, ): void { $this->writeAuthorPage( - $renderer ?? new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest), + $renderer ?? new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify), $siteConfig, $author, $entries, diff --git a/src/Build/CollectionListingWriter.php b/src/Build/CollectionListingWriter.php index ceaaa9c..0a04610 100644 --- a/src/Build/CollectionListingWriter.php +++ b/src/Build/CollectionListingWriter.php @@ -33,7 +33,7 @@ public function write( int $workerCount = 1, bool $noWrite = false, ): int { - $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest); + $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify); $perPage = $collection->entriesPerPage; if ($perPage <= 0) { $perPage = count($entries) ?: 1; diff --git a/src/Build/DateArchiveWriter.php b/src/Build/DateArchiveWriter.php index 4916c1c..0ee0623 100644 --- a/src/Build/DateArchiveWriter.php +++ b/src/Build/DateArchiveWriter.php @@ -30,7 +30,7 @@ public function write( int $workerCount = 1, bool $noWrite = false, ): int { - $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest); + $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify); $byYear = []; $byMonth = []; diff --git a/src/Build/EntryRenderer.php b/src/Build/EntryRenderer.php index 6f6137e..b1d370e 100644 --- a/src/Build/EntryRenderer.php +++ b/src/Build/EntryRenderer.php @@ -67,7 +67,9 @@ public function render( $cacheContext = $this->cacheContext($siteConfig, $entry, $permalink, $navigation, $crossRefResolver, $navigationPager); $cached = $this->cache->get($entry->filePath, $cacheContext); if ($cached !== null) { - return $this->dispatchRenderFinished($siteConfig, $entry, $permalink, $cached); + $html = $this->dispatchRenderFinished($siteConfig, $entry, $permalink, $cached); + + return $siteConfig->minify ? OutputMinifier::html($html) : $html; } } @@ -91,7 +93,9 @@ public function render( $this->cache->set($entry->filePath, $html, $cacheContext); } - return $this->dispatchRenderFinished($siteConfig, $entry, $permalink, $html); + $html = $this->dispatchRenderFinished($siteConfig, $entry, $permalink, $html); + + return $siteConfig->minify ? OutputMinifier::html($html) : $html; } private function dispatchRenderFinished(SiteConfig $siteConfig, Entry $entry, string $permalink, string $html): string diff --git a/src/Build/NotFoundPageWriter.php b/src/Build/NotFoundPageWriter.php index 3586036..9a91095 100644 --- a/src/Build/NotFoundPageWriter.php +++ b/src/Build/NotFoundPageWriter.php @@ -19,7 +19,7 @@ public function write(SiteConfig $siteConfig, string $outputDir, ?Navigation $na { $rootPath = './'; $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); - $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest); + $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify); $html = $renderer->render('errors/404', [ 'siteTitle' => $siteConfig->title, 'nav' => $navigation, diff --git a/src/Build/OutputMinifier.php b/src/Build/OutputMinifier.php new file mode 100644 index 0000000..1682809 --- /dev/null +++ b/src/Build/OutputMinifier.php @@ -0,0 +1,87 @@ +]*>.*?)~is', + $html, + -1, + PREG_SPLIT_DELIM_CAPTURE, + ); + if ($protectedParts === false) { + return $html; + } + + $minified = ''; + $previousPartProtected = false; + foreach ($protectedParts as $part) { + if ($part === '') { + continue; + } + + if (preg_match('~^<(?:pre|textarea|script|style)\b~i', $part) === 1) { + $minified = preg_replace('~(?<=>)\s+$~', '', $minified) ?? $minified; + $minified .= $part; + $previousPartProtected = true; + continue; + } + + $fragment = self::minifyHtmlFragment($part); + if ($previousPartProtected) { + $fragment = preg_replace('~^\s+(?=<)~', '', $fragment) ?? $fragment; + } + + $minified .= $fragment; + $previousPartProtected = false; + } + + return trim($minified); + } + + private static function minifyHtmlFragment(string $html): string + { + if (trim($html) === '') { + return ''; + } + + $tokens = preg_split('~(<[^>]+>)~', $html, -1, PREG_SPLIT_DELIM_CAPTURE); + if ($tokens === false) { + return $html; + } + + $minified = ''; + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + + if (str_starts_with($token, '<')) { + $minified .= $token; + continue; + } + + $minified .= preg_replace('~[ \t\r\n\f]+~', ' ', $token) ?? $token; + } + + return preg_replace('~>\s+<~', '><', $minified) ?? $minified; + } +} diff --git a/src/Build/PageTemplateRenderer.php b/src/Build/PageTemplateRenderer.php index 3e314c2..083bb72 100644 --- a/src/Build/PageTemplateRenderer.php +++ b/src/Build/PageTemplateRenderer.php @@ -21,6 +21,7 @@ public function __construct( private readonly TemplateResolver $templateResolver, private readonly string $themeName, private readonly ?AssetFingerprintManifest $assetManifest = null, + private readonly bool $minify = true, ) {} /** @@ -54,6 +55,8 @@ public function render(string $templateName, array $variables, string $rootPath) $html = ($this->templateClosures[$templatePath])($variables); - return $this->templateContexts[$this->themeName]->rewriteHtml($html, $rootPath); + $html = $this->templateContexts[$this->themeName]->rewriteHtml($html, $rootPath); + + return $this->minify ? OutputMinifier::html($html) : $html; } } diff --git a/src/Build/RedirectPageWriter.php b/src/Build/RedirectPageWriter.php index a5b06a8..f0a954c 100644 --- a/src/Build/RedirectPageWriter.php +++ b/src/Build/RedirectPageWriter.php @@ -58,6 +58,10 @@ public function write( HTML; + if ($siteConfig?->minify ?? true) { + $html = OutputMinifier::html($html); + } + if ($noWrite) { return; } diff --git a/src/Build/TaxonomyPageWriter.php b/src/Build/TaxonomyPageWriter.php index 74f0697..55a82e3 100644 --- a/src/Build/TaxonomyPageWriter.php +++ b/src/Build/TaxonomyPageWriter.php @@ -30,7 +30,7 @@ public function write( ?Navigation $navigation = null, bool $noWrite = false, ): int { - $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest); + $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify); $pageCount = 0; foreach ($taxonomyData as $taxonomyName => $terms) { diff --git a/src/Content/Model/SiteConfig.php b/src/Content/Model/SiteConfig.php index e6630f9..904fec6 100644 --- a/src/Content/Model/SiteConfig.php +++ b/src/Content/Model/SiteConfig.php @@ -37,5 +37,6 @@ public function __construct( public ?string $editPageUrl = null, public ?string $reportIssueUrl = null, public bool $authorPages = false, + public bool $minify = true, ) {} } diff --git a/src/Content/Parser/SiteConfigParser.php b/src/Content/Parser/SiteConfigParser.php index 1fe756a..604ae52 100644 --- a/src/Content/Parser/SiteConfigParser.php +++ b/src/Content/Parser/SiteConfigParser.php @@ -88,6 +88,7 @@ public function parse(string $filePath): SiteConfig editPageUrl: self::parseOptionalString($data['edit_page'] ?? null), reportIssueUrl: self::parseOptionalString($data['report_issue'] ?? null), authorPages: (bool) ($data['author_pages'] ?? false), + minify: (bool) ($data['minify'] ?? true), ); } diff --git a/tests/Unit/Build/EntryRendererTest.php b/tests/Unit/Build/EntryRendererTest.php index 1e6bb99..9f3b4ea 100644 --- a/tests/Unit/Build/EntryRendererTest.php +++ b/tests/Unit/Build/EntryRendererTest.php @@ -552,6 +552,7 @@ private function createSiteConfig( ?string $editPageUrl = null, ?string $reportIssueUrl = null, bool $authorPages = false, + bool $minify = true, ): SiteConfig { return new SiteConfig( @@ -573,6 +574,7 @@ private function createSiteConfig( editPageUrl: $editPageUrl, reportIssueUrl: $reportIssueUrl, authorPages: $authorPages, + minify: $minify, ); } diff --git a/tests/Unit/Build/OutputMinifierTest.php b/tests/Unit/Build/OutputMinifierTest.php new file mode 100644 index 0000000..0df5304 --- /dev/null +++ b/tests/Unit/Build/OutputMinifierTest.php @@ -0,0 +1,65 @@ + + + +

+ Hello +

+

One + two

+ + + HTML; + + assertSame('

Hello

One two

', OutputMinifier::html($html)); + } + + public function testPreservesWhitespaceSensitiveElementBodies(): void + { + $html = <<<'HTML' + + +
Line 1
+                Line 2
+ + + + + HTML; + + assertSame( + <<<'HTML' +
Line 1
+                Line 2
+ HTML, + OutputMinifier::html($html), + ); + } +} diff --git a/tests/Unit/Build/PageTemplateRendererTest.php b/tests/Unit/Build/PageTemplateRendererTest.php index 705ca03..0c6c940 100644 --- a/tests/Unit/Build/PageTemplateRendererTest.php +++ b/tests/Unit/Build/PageTemplateRendererTest.php @@ -16,6 +16,7 @@ use SplFileInfo; use function PHPUnit\Framework\assertStringContainsString; +use function PHPUnit\Framework\assertSame; final class PageTemplateRendererTest extends TestCase { @@ -83,4 +84,34 @@ public function testProvidesTranslationHelperToTemplatesAndPartials(): void assertStringContainsString('

Поиск

', $html); assertStringContainsString('

Далее

', $html); } + + public function testMinifiesRenderedHtmlByDefault(): void + { + $registry = new ThemeRegistry(); + $registry->register(new Theme('test', $this->tempDir)); + $registry->register(new Theme('minimal', dirname(__DIR__, 3) . '/themes/minimal')); + $resolver = new TemplateResolver($registry); + $renderer = new PageTemplateRenderer($resolver, 'test'); + + $html = $renderer->render('example', [ + 'ui' => UiText::forTheme('en', $resolver, 'minimal'), + ], './'); + + assertSame('

Search

Next

', $html); + } + + public function testCanKeepRenderedHtmlUnminified(): void + { + $registry = new ThemeRegistry(); + $registry->register(new Theme('test', $this->tempDir)); + $registry->register(new Theme('minimal', dirname(__DIR__, 3) . '/themes/minimal')); + $resolver = new TemplateResolver($registry); + $renderer = new PageTemplateRenderer($resolver, 'test', minify: false); + + $html = $renderer->render('example', [ + 'ui' => UiText::forTheme('en', $resolver, 'minimal'), + ], './'); + + assertStringContainsString("

Search

\n

Next

", $html); + } } diff --git a/tests/Unit/Content/Parser/SiteConfigParserTest.php b/tests/Unit/Content/Parser/SiteConfigParserTest.php index 893736f..9f8edb6 100644 --- a/tests/Unit/Content/Parser/SiteConfigParserTest.php +++ b/tests/Unit/Content/Parser/SiteConfigParserTest.php @@ -38,6 +38,20 @@ public function testParseSiteConfig(): void assertSame('local', $config->theme); assertSame('Solarized (dark)', $config->highlightTheme); assertTrue($config->assets->fingerprint); + assertTrue($config->minify); + } + + public function testParseMinifyConfigCanDisableOutputMinification(): void + { + $filePath = sys_get_temp_dir() . '/yiipress-site-config-' . uniqid() . '.yaml'; + file_put_contents($filePath, "title: Test\nlanguages: [en]\nminify: false\n"); + + $parser = new SiteConfigParser(); + $config = $parser->parse($filePath); + + assertFalse($config->minify); + + unlink($filePath); } public function testParseAssetConfigCanDisableFingerprinting(): void From 5566543a73b090b5d2d18cf45a52bbe6672772d5 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sun, 7 Jun 2026 23:30:15 +0300 Subject: [PATCH 2/2] Address output minifier review comments --- src/Build/AuthorPageWriter.php | 16 ++++-- src/Build/OutputMinifier.php | 67 ++++++++++++++++++++----- tests/Unit/Build/OutputMinifierTest.php | 39 ++++++++++++-- 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/Build/AuthorPageWriter.php b/src/Build/AuthorPageWriter.php index 97d21fd..fa9645b 100644 --- a/src/Build/AuthorPageWriter.php +++ b/src/Build/AuthorPageWriter.php @@ -40,7 +40,7 @@ public function write( return 0; } - $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify); + $renderer = $this->createPageTemplateRenderer($siteConfig); $this->writeIndex($siteConfig, $authors, $outputDir, $navigation, $noWrite); $pageCount = 1; @@ -65,7 +65,7 @@ public function writeIndex( bool $noWrite = false, ): void { $this->writeIndexPage( - new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify), + $this->createPageTemplateRenderer($siteConfig), $siteConfig, $authors, $outputDir, @@ -89,7 +89,7 @@ public function writeAuthor( bool $noWrite = false, ): void { $this->writeAuthorPage( - $renderer ?? new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest, $siteConfig->minify), + $renderer ?? $this->createPageTemplateRenderer($siteConfig), $siteConfig, $author, $entries, @@ -100,6 +100,16 @@ public function writeAuthor( ); } + private function createPageTemplateRenderer(SiteConfig $siteConfig): PageTemplateRenderer + { + return new PageTemplateRenderer( + $this->templateResolver, + $siteConfig->theme, + $this->assetManifest, + $siteConfig->minify, + ); + } + /** * @param array $authors */ diff --git a/src/Build/OutputMinifier.php b/src/Build/OutputMinifier.php index 1682809..272985e 100644 --- a/src/Build/OutputMinifier.php +++ b/src/Build/OutputMinifier.php @@ -6,12 +6,17 @@ use function preg_replace; use function preg_split; -use function preg_match; +use function preg_match_all; +use function strlen; use function str_starts_with; +use function substr; use function trim; final class OutputMinifier { + private const string PROTECTED_ELEMENT_PATTERN = '~<(?pre|textarea|script|style)\b(?:[^>"\']+|"[^"]*"|\'[^\']*\')*>.*?>~is'; + private const string TAG_PATTERN = '~(<(?:[^>"\']+|"[^"]*"|\'[^\']*\')*>)~'; + /** * Minifies generated HTML while preserving whitespace-sensitive element bodies. */ @@ -21,31 +26,26 @@ public static function html(string $html): string return ''; } - $protectedParts = preg_split( - '~(<(?:pre|textarea|script|style)\b[^>]*>.*?)~is', - $html, - -1, - PREG_SPLIT_DELIM_CAPTURE, - ); - if ($protectedParts === false) { + $protectedParts = self::splitProtectedParts($html); + if ($protectedParts === null) { return $html; } $minified = ''; $previousPartProtected = false; foreach ($protectedParts as $part) { - if ($part === '') { + if ($part['html'] === '') { continue; } - if (preg_match('~^<(?:pre|textarea|script|style)\b~i', $part) === 1) { + if ($part['protected']) { $minified = preg_replace('~(?<=>)\s+$~', '', $minified) ?? $minified; - $minified .= $part; + $minified .= $part['html']; $previousPartProtected = true; continue; } - $fragment = self::minifyHtmlFragment($part); + $fragment = self::minifyHtmlFragment($part['html']); if ($previousPartProtected) { $fragment = preg_replace('~^\s+(?=<)~', '', $fragment) ?? $fragment; } @@ -57,13 +57,54 @@ public static function html(string $html): string return trim($minified); } + /** + * @return list|null + */ + private static function splitProtectedParts(string $html): ?array + { + $matched = preg_match_all(self::PROTECTED_ELEMENT_PATTERN, $html, $matches, PREG_OFFSET_CAPTURE); + if ($matched === false) { + return null; + } + + if ($matched === 0) { + return [['html' => $html, 'protected' => false]]; + } + + $parts = []; + $offset = 0; + foreach ($matches[0] as [$match, $position]) { + if ($position > $offset) { + $parts[] = [ + 'html' => substr($html, $offset, $position - $offset), + 'protected' => false, + ]; + } + + $parts[] = [ + 'html' => $match, + 'protected' => true, + ]; + $offset = $position + strlen($match); + } + + if ($offset < strlen($html)) { + $parts[] = [ + 'html' => substr($html, $offset), + 'protected' => false, + ]; + } + + return $parts; + } + private static function minifyHtmlFragment(string $html): string { if (trim($html) === '') { return ''; } - $tokens = preg_split('~(<[^>]+>)~', $html, -1, PREG_SPLIT_DELIM_CAPTURE); + $tokens = preg_split(self::TAG_PATTERN, $html, -1, PREG_SPLIT_DELIM_CAPTURE); if ($tokens === false) { return $html; } diff --git a/tests/Unit/Build/OutputMinifierTest.php b/tests/Unit/Build/OutputMinifierTest.php index 0df5304..d7a8a41 100644 --- a/tests/Unit/Build/OutputMinifierTest.php +++ b/tests/Unit/Build/OutputMinifierTest.php @@ -34,10 +34,10 @@ public function testPreservesWhitespaceSensitiveElementBodies(): void $html = <<<'HTML' -
Line 1
+            
Line 1
                 Line 2