Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions src/Build/AssetFileWriter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace YiiPress\Build;

use RuntimeException;

use function clearstatcache;
use function file_get_contents;
use function file_put_contents;
use function filemtime;
use function is_file;
use function sprintf;
use function touch;

final class AssetFileWriter
{
public function writeIfChanged(string $sourcePath, string $targetPath, bool $minify): bool
{
if (!$minify || !AssetMinifier::supports($sourcePath)) {
return FileCopy::copyIfChanged($sourcePath, $targetPath);
}

$content = file_get_contents($sourcePath);
if ($content === false) {
throw new RuntimeException(sprintf('Unable to read asset "%s".', $sourcePath));
}

$content = AssetMinifier::minify($sourcePath, $content);
if ($this->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;
}
}
253 changes: 253 additions & 0 deletions src/Build/AssetMinifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
<?php

declare(strict_types=1);

namespace YiiPress\Build;

use function pathinfo;
use function preg_match;
use function preg_replace;
use function rtrim;
use function str_contains;
use function strlen;
use function strtolower;
use function substr;
use function trim;

use const PATHINFO_EXTENSION;

final class AssetMinifier
{
public static function supports(string $path): bool
{
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));

return $extension === 'css' || $extension === 'js';
}

public static function minify(string $path, string $content): string
{
return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) {
'css' => 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;
}
}
5 changes: 3 additions & 2 deletions src/Build/ContentAssetCopier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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++;
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/Build/ThemeAssetCopier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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++;
}
}
Expand Down
Loading