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..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);
+ $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),
+ $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),
+ $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/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..272985e
--- /dev/null
+++ b/src/Build/OutputMinifier.php
@@ -0,0 +1,128 @@
+pre|textarea|script|style)\b(?:[^>"\']+|"[^"]*"|\'[^\']*\')*>.*?\k>~is';
+ private const string TAG_PATTERN = '~(<(?:[^>"\']+|"[^"]*"|\'[^\']*\')*>)~';
+
+ /**
+ * Minifies generated HTML while preserving whitespace-sensitive element bodies.
+ */
+ public static function html(string $html): string
+ {
+ if ($html === '') {
+ return '';
+ }
+
+ $protectedParts = self::splitProtectedParts($html);
+ if ($protectedParts === null) {
+ return $html;
+ }
+
+ $minified = '';
+ $previousPartProtected = false;
+ foreach ($protectedParts as $part) {
+ if ($part['html'] === '') {
+ continue;
+ }
+
+ if ($part['protected']) {
+ $minified = preg_replace('~(?<=>)\s+$~', '', $minified) ?? $minified;
+ $minified .= $part['html'];
+ $previousPartProtected = true;
+ continue;
+ }
+
+ $fragment = self::minifyHtmlFragment($part['html']);
+ if ($previousPartProtected) {
+ $fragment = preg_replace('~^\s+(?=<)~', '', $fragment) ?? $fragment;
+ }
+
+ $minified .= $fragment;
+ $previousPartProtected = false;
+ }
+
+ 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(self::TAG_PATTERN, $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(