diff --git a/docs/configuration.md b/docs/configuration.md index bdce7f4..8e96e4d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,7 +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 +- **minify** — minify generated HTML output and copied CSS/JavaScript assets (default: `true`); set to `false` to keep rendered template and asset 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) @@ -216,14 +216,15 @@ the current output page, so they remain valid when `base_url` contains a deploym ### Output minification -Generated HTML pages are minified by default: +Generated HTML pages and copied CSS/JavaScript assets 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. +Set `minify: false` to keep template indentation and line breaks in generated `*.html` files +and to copy `*.css` / `*.js` assets without rewriting them. Whitespace inside `pre`, `textarea`, +`script`, and `style` elements is preserved either way. ### Editor diff --git a/roadmap.md b/roadmap.md index f36c641..a810569 100644 --- a/roadmap.md +++ b/roadmap.md @@ -76,7 +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 +- [x] Configurable generated HTML and CSS/JavaScript asset minification ## Priority 6: SEO and web standards diff --git a/src/Build/AssetFileWriter.php b/src/Build/AssetFileWriter.php new file mode 100644 index 0000000..cf0b012 --- /dev/null +++ b/src/Build/AssetFileWriter.php @@ -0,0 +1,58 @@ +destinationMatchesContent($targetPath, $content)) { + return false; + } + + if (file_put_contents($targetPath, $content) === false) { + throw new RuntimeException(sprintf('Unable to write asset "%s".', $targetPath)); + } + + $mtime = filemtime($sourcePath); + if ($mtime !== false) { + touch($targetPath, $mtime); + } + + return true; + } + + private function destinationMatchesContent(string $targetPath, string $content): bool + { + if (!is_file($targetPath)) { + return false; + } + + clearstatcache(true, $targetPath); + $targetContent = file_get_contents($targetPath); + + return $targetContent === $content; + } +} diff --git a/src/Build/AssetMinifier.php b/src/Build/AssetMinifier.php new file mode 100644 index 0000000..298c94a --- /dev/null +++ b/src/Build/AssetMinifier.php @@ -0,0 +1,253 @@ + self::css($content), + 'js' => self::js($content), + default => $content, + }; + } + + private static function css(string $content): string + { + $content = self::stripComments($content, preserveNewLines: false, stripLineComments: false); + $content = self::collapseCssWhitespace($content); + $content = (string) preg_replace('/\s*([{}:;,>~=()\[\]])\s*/', '$1', $content); + $content = (string) preg_replace('/;}/', '}', $content); + + return trim($content); + } + + private static function js(string $content): string + { + $content = self::stripComments($content, preserveNewLines: true, stripLineComments: true, preserveJsRegex: true); + + return rtrim((string) preg_replace('/[ \t]+/', ' ', $content)); + } + + private static function stripComments(string $content, bool $preserveNewLines, bool $stripLineComments = true, bool $preserveJsRegex = false): string + { + $length = strlen($content); + $result = ''; + $quote = ''; + $escaped = false; + + for ($i = 0; $i < $length; $i++) { + $char = $content[$i]; + $next = $i + 1 < $length ? $content[$i + 1] : ''; + + if ($quote !== '') { + $result .= $char; + if ($escaped) { + $escaped = false; + continue; + } + if ($char === '\\') { + $escaped = true; + continue; + } + if ($char === $quote) { + $quote = ''; + } + continue; + } + + if ($char === '"' || $char === "'" || $char === '`') { + $quote = $char; + $result .= $char; + continue; + } + + if ($char === '/' && $next !== '/' && $next !== '*' && $preserveJsRegex && self::startsRegexLiteral($result)) { + $result .= $char; + $inCharacterClass = false; + $regexEscaped = false; + $i++; + + while ($i < $length) { + $regexChar = $content[$i]; + $result .= $regexChar; + + if ($regexEscaped) { + $regexEscaped = false; + $i++; + continue; + } + + if ($regexChar === '\\') { + $regexEscaped = true; + $i++; + continue; + } + + if ($regexChar === '[') { + $inCharacterClass = true; + $i++; + continue; + } + + if ($regexChar === ']') { + $inCharacterClass = false; + $i++; + continue; + } + + if ($regexChar === '/' && !$inCharacterClass) { + break; + } + + $i++; + } + + while ($i + 1 < $length && preg_match('/[A-Za-z]/', $content[$i + 1]) === 1) { + $i++; + $result .= $content[$i]; + } + + continue; + } + + if ($char === '/' && $next === '*') { + $comment = ''; + $i += 2; + while ($i < $length) { + if ($content[$i] === '*' && $i + 1 < $length && $content[$i + 1] === '/') { + $i++; + break; + } + if ($preserveNewLines && ($content[$i] === "\n" || $content[$i] === "\r")) { + $comment .= $content[$i]; + } + $i++; + } + $result .= $preserveNewLines && str_contains($comment, "\n") ? $comment : ' '; + continue; + } + + if ($stripLineComments && $char === '/' && $next === '/') { + $i += 2; + while ($i < $length && $content[$i] !== "\n") { + $i++; + } + if ($i < $length) { + $result .= "\n"; + } + continue; + } + + $result .= $char; + } + + return $result; + } + + private static function startsRegexLiteral(string $code): bool + { + $code = rtrim($code); + if ($code === '') { + return true; + } + + $last = substr($code, -1); + if (match ($last) { + '(', '[', '{', '=', ',', ':', ';', '!', '&', '|', '?', '+', '-', '*', '~', '^', '<', '>', '%' => true, + default => false, + }) { + return true; + } + + return preg_match('/(?:^|[^A-Za-z0-9_$])(?:return|throw|case|delete|typeof|void|new|yield)$/', $code) === 1; + } + + private static function collapseCssWhitespace(string $content): string + { + $length = strlen($content); + $result = ''; + $quote = ''; + $escaped = false; + $pendingSpace = false; + + for ($i = 0; $i < $length; $i++) { + $char = $content[$i]; + + if ($quote !== '') { + if ($pendingSpace) { + $result .= ' '; + $pendingSpace = false; + } + $result .= $char; + if ($escaped) { + $escaped = false; + continue; + } + if ($char === '\\') { + $escaped = true; + continue; + } + if ($char === $quote) { + $quote = ''; + } + continue; + } + + if ($char === '"' || $char === "'") { + if ($pendingSpace) { + $result .= ' '; + $pendingSpace = false; + } + $quote = $char; + $result .= $char; + continue; + } + + if ($char === ' ' || $char === "\t" || $char === "\n" || $char === "\r") { + $pendingSpace = $result !== ''; + continue; + } + + if ($pendingSpace && self::needsCssSpace(substr($result, -1), $char)) { + $result .= ' '; + } + $pendingSpace = false; + $result .= $char; + } + + return $result; + } + + private static function needsCssSpace(string $previous, string $next): bool + { + return self::isIdentifierChar($previous) && self::isIdentifierChar($next); + } + + private static function isIdentifierChar(string $char): bool + { + return $char !== '' && preg_match('/[A-Za-z0-9_-]/', $char) === 1; + } +} diff --git a/src/Build/ContentAssetCopier.php b/src/Build/ContentAssetCopier.php index 931d7df..f9e1b8f 100644 --- a/src/Build/ContentAssetCopier.php +++ b/src/Build/ContentAssetCopier.php @@ -21,9 +21,10 @@ final class ContentAssetCopier /** * @return int number of assets copied */ - public function copy(string $contentDir, string $outputDir, ?AssetFingerprintManifest $assetManifest = null, bool $noWrite = false): int + public function copy(string $contentDir, string $outputDir, ?AssetFingerprintManifest $assetManifest = null, bool $noWrite = false, bool $minify = true): int { $copied = 0; + $writer = new AssetFileWriter(); foreach ($this->mappings($contentDir) as $sourcePath => $targetRelativePath) { $resolvedTarget = $assetManifest?->resolve($targetRelativePath) ?? $targetRelativePath; @@ -39,7 +40,7 @@ public function copy(string $contentDir, string $outputDir, ?AssetFingerprintMan throw new RuntimeException(sprintf('Directory "%s" was not created', $targetDir)); } - if (FileCopy::copyIfChanged($sourcePath, $targetPath)) { + if ($writer->writeIfChanged($sourcePath, $targetPath, $minify)) { $copied++; } } diff --git a/src/Build/ThemeAssetCopier.php b/src/Build/ThemeAssetCopier.php index 8af3747..1b40547 100644 --- a/src/Build/ThemeAssetCopier.php +++ b/src/Build/ThemeAssetCopier.php @@ -18,9 +18,10 @@ final class ThemeAssetCopier /** * @return int number of assets copied */ - public function copy(ThemeRegistry $themeRegistry, string $outputDir, ?AssetFingerprintManifest $assetManifest = null, bool $noWrite = false): int + public function copy(ThemeRegistry $themeRegistry, string $outputDir, ?AssetFingerprintManifest $assetManifest = null, bool $noWrite = false, bool $minify = true): int { $copied = 0; + $writer = new AssetFileWriter(); foreach ($this->mappings($themeRegistry) as $sourcePath => $targetRelativePath) { $resolvedTarget = $assetManifest?->resolve($targetRelativePath) ?? $targetRelativePath; @@ -36,7 +37,7 @@ public function copy(ThemeRegistry $themeRegistry, string $outputDir, ?AssetFing throw new RuntimeException(sprintf('Directory "%s" was not created', $targetDir)); } - if (FileCopy::copyIfChanged($sourcePath, $targetPath)) { + if ($writer->writeIfChanged($sourcePath, $targetPath, $minify)) { $copied++; } } diff --git a/src/Console/BuildCommand.php b/src/Console/BuildCommand.php index 4e1e959..ab2c1b3 100644 --- a/src/Console/BuildCommand.php +++ b/src/Console/BuildCommand.php @@ -10,6 +10,7 @@ use YiiPress\Build\BuildManifest; use YiiPress\Build\BuildDiagnostics; use YiiPress\Build\BuildProfile; +use YiiPress\Build\AssetFileWriter; use YiiPress\Build\CollectionListingWriter; use YiiPress\Build\ContentAssetCopier; use YiiPress\Build\DateArchiveWriter; @@ -20,7 +21,6 @@ use YiiPress\Build\SearchIndexGenerator; use YiiPress\Build\ThemeAssetCopier; use YiiPress\Build\FeedGenerator; -use YiiPress\Build\FileCopy; use YiiPress\Build\ParallelEntryWriter; use YiiPress\Build\ParallelTaskRunner; use YiiPress\Build\SitemapGenerator; @@ -623,8 +623,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $profile->switchTo('copy assets'); - $assetsCopied = $contentAssetCopier->copy($contentDir, $outputDir, $assetManifest, $noWrite); - $assetsCopied += $themeAssetCopier->copy($this->themeRegistry, $outputDir, $assetManifest, $noWrite); + $assetsCopied = $contentAssetCopier->copy($contentDir, $outputDir, $assetManifest, $noWrite, $siteConfig->minify); + $assetsCopied += $themeAssetCopier->copy($this->themeRegistry, $outputDir, $assetManifest, $noWrite, $siteConfig->minify); + $assetWriter = new AssetFileWriter(); foreach ($pipelineAssetMappings as $source => $target) { $resolvedTarget = $assetManifest?->resolve($target) ?? $target; @@ -637,7 +638,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!is_dir($targetDir) && !mkdir($targetDir, 0o755, true) && !is_dir($targetDir)) { throw new RuntimeException(sprintf('Directory "%s" was not created', $targetDir)); } - if (FileCopy::copyIfChanged($source, $targetPath)) { + if ($assetWriter->writeIfChanged($source, $targetPath, $siteConfig->minify)) { $assetsCopied++; } } diff --git a/tests/Unit/Build/AssetFileWriterTest.php b/tests/Unit/Build/AssetFileWriterTest.php new file mode 100644 index 0000000..1036247 --- /dev/null +++ b/tests/Unit/Build/AssetFileWriterTest.php @@ -0,0 +1,61 @@ +tempDir = sys_get_temp_dir() . '/yiipress-asset-writer-test-' . uniqid(); + mkdir($this->tempDir, 0o755, true); + } + + protected function tearDown(): void + { + foreach (glob($this->tempDir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->tempDir); + } + + public function testWritesMinifiedSupportedAssets(): void + { + $source = $this->tempDir . '/style.css'; + $target = $this->tempDir . '/style.out.css'; + file_put_contents($source, 'body { color: red; }'); + + $writer = new AssetFileWriter(); + + assertTrue($writer->writeIfChanged($source, $target, minify: true)); + assertStringEqualsFile($target, 'body{color:red}'); + assertFalse($writer->writeIfChanged($source, $target, minify: true)); + } + + public function testCopiesUnsupportedOrUnminifiedAssetsUnchanged(): void + { + $source = $this->tempDir . '/logo.svg'; + $target = $this->tempDir . '/logo.out.svg'; + file_put_contents($source, ' '); + + $writer = new AssetFileWriter(); + + assertTrue($writer->writeIfChanged($source, $target, minify: true)); + assertStringEqualsFile($target, ' '); + + file_put_contents($source, ''); + + assertTrue($writer->writeIfChanged($source, $target, minify: false)); + assertStringEqualsFile($target, ''); + } +} diff --git a/tests/Unit/Build/AssetMinifierTest.php b/tests/Unit/Build/AssetMinifierTest.php new file mode 100644 index 0000000..52ebf41 --- /dev/null +++ b/tests/Unit/Build/AssetMinifierTest.php @@ -0,0 +1,63 @@ +outputDir . '/blog/assets/banner.svg', ''); } + public function testMinifiesCssAndJavaScriptAssetsByDefault(): void + { + file_put_contents($this->contentDir . '/blog/assets/app.css', "/* theme */\nbody { color: red; }\n"); + file_put_contents($this->contentDir . '/blog/assets/app.js', "const value = 1; // keep code\nconsole.log(value);\n"); + + $copier = new ContentAssetCopier(); + $copied = $copier->copy($this->contentDir, $this->outputDir); + + assertSame(2, $copied); + assertStringEqualsFile($this->outputDir . '/blog/assets/app.css', 'body{color:red}'); + assertStringEqualsFile($this->outputDir . '/blog/assets/app.js', "const value = 1; \nconsole.log(value);"); + } + + public function testCanCopyCssAndJavaScriptAssetsWithoutMinifying(): void + { + $css = "/* theme */\nbody { color: red; }\n"; + $js = "const value = 1; // keep code\nconsole.log(value);\n"; + file_put_contents($this->contentDir . '/blog/assets/app.css', $css); + file_put_contents($this->contentDir . '/blog/assets/app.js', $js); + + $copier = new ContentAssetCopier(); + $copied = $copier->copy($this->contentDir, $this->outputDir, minify: false); + + assertSame(2, $copied); + assertStringEqualsFile($this->outputDir . '/blog/assets/app.css', $css); + assertStringEqualsFile($this->outputDir . '/blog/assets/app.js', $js); + } + public function testSkipsUnchangedOutputAsset(): void { $source = $this->contentDir . '/blog/assets/banner.svg'; diff --git a/tests/Unit/Build/ThemeAssetCopierTest.php b/tests/Unit/Build/ThemeAssetCopierTest.php index 90714f6..64405b5 100644 --- a/tests/Unit/Build/ThemeAssetCopierTest.php +++ b/tests/Unit/Build/ThemeAssetCopierTest.php @@ -16,7 +16,7 @@ use function PHPUnit\Framework\assertFileExists; use function PHPUnit\Framework\assertSame; -use function PHPUnit\Framework\assertStringContainsString; +use function PHPUnit\Framework\assertStringEqualsFile; final class ThemeAssetCopierTest extends TestCase { @@ -46,7 +46,22 @@ public function testCopiesThemeAssetsToOutput(): void assertSame(1, $copied); assertFileExists($this->tempDir . '/output/assets/theme/style.css'); - assertStringContainsString('color: red', file_get_contents($this->tempDir . '/output/assets/theme/style.css')); + assertStringEqualsFile($this->tempDir . '/output/assets/theme/style.css', 'body{color:red}'); + } + + public function testCanCopyThemeAssetsWithoutMinifying(): void + { + $css = 'body { color: red; }'; + file_put_contents($this->tempDir . '/theme/assets/style.css', $css); + + $registry = new ThemeRegistry(); + $registry->register(new Theme('test', $this->tempDir . '/theme')); + + $copier = new ThemeAssetCopier(); + $copied = $copier->copy($registry, $this->tempDir . '/output', minify: false); + + assertSame(1, $copied); + assertStringEqualsFile($this->tempDir . '/output/assets/theme/style.css', $css); } public function testReturnsZeroWhenNoAssetsDir(): void