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