Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds a mix Twig filter for Laravel Mix theme assets. #1179

Draft
wants to merge 8 commits into
base: develop
Choose a base branch
from
Draft
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
11 changes: 11 additions & 0 deletions modules/cms/tests/fixtures/npm/package-mixtheme.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type": "module",
"workspaces": {
"packages": [
"modules/cms/tests/fixtures/themes/mixtest"
]
},
"devDependencies": {
"laravel-mix": "^6.0.41"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
h1 {
color: red;
}
10 changes: 10 additions & 0 deletions modules/cms/tests/fixtures/themes/mixtest/winter.mix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const mix = require('laravel-mix');

mix.setPublicPath(__dirname)
.options({
manifest: true,
})
.version()

// Render Tailwind style
.postCss('assets/src/css/theme.css', 'assets/dist/css/theme.css')
76 changes: 76 additions & 0 deletions modules/cms/tests/twig/MixFunctionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace Cms\Tests\Twig;

use Cms\Classes\Controller;
use Cms\Classes\Theme;
use Cms\Twig\Extension;
use System\Tests\Bootstrap\TestCase;
use Winter\Storm\Support\Facades\Config;
use Winter\Storm\Support\Facades\Event;
use Winter\Storm\Support\Facades\File;
use Winter\Storm\Support\Facades\Twig;

class MixFunctionTest extends TestCase
{
protected string $themeName = 'mixtest';

protected function setUp(): void
{
parent::setUp();

if (!is_dir(base_path('node_modules'))) {
$this->markTestSkipped('This test requires node_modules to be installed');
}

if (!is_file(base_path('node_modules/.bin/mix'))) {
$this->markTestSkipped('This test requires the mix package to be installed');
}

$this->originalThemesPath = Config::get('cms.themesPath');
Config::set('cms.themesPath', '/modules/cms/tests/fixtures/themes');

$this->themePath = base_path("modules/cms/tests/fixtures/themes/$this->themeName");

Config::set('cms.activeTheme', $this->themeName);

Event::flush('cms.theme.getActiveTheme');
Theme::resetCache();
}

protected function tearDown(): void
{
File::deleteDirectory("modules/cms/tests/fixtures/themes/$this->themeName/assets/dist");
File::delete("modules/cms/tests/fixtures/themes/$this->themeName/mix-manifest.json");

Config::set('cms.themesPath', $this->originalThemesPath);

parent::tearDown();
}

public function testGeneratesAssetUrl(): void
{
$theme = Theme::getActiveTheme();
$packageName = "theme-$this->themeName";

$this->artisan('mix:compile', [
$packageName,
'--manifest' => 'modules/cms/tests/fixtures/npm/package-mixtheme.json',
'--disable-tty' => true,
])->assertExitCode(0);

$this->assertFileExists($theme->getPath($theme->getDirName() . '/mix-manifest.json'));

$controller = Controller::getController() ?: new Controller();

$extension = new Extension();
$extension->setController($controller);

$this->app->make('twig.environment')
->addExtension($extension);

$contents = Twig::parse("{{ mix(['assets/dist/css/theme.css'], '$packageName') }}");

$this->assertStringContainsString('/assets/dist/css/theme.css?id=', $contents);
}
}
7 changes: 7 additions & 0 deletions modules/cms/twig/Extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use Block;
use Cms\Classes\Controller;
use Event;
use System\Classes\Asset\Mix;
use System\Classes\Asset\Vite;
use Twig\Extension\AbstractExtension as TwigExtension;
use Twig\TwigFilter as TwigSimpleFilter;
Expand Down Expand Up @@ -53,6 +54,7 @@ public function getFunctions(): array
new TwigSimpleFunction('component', [$this, 'componentFunction'], $options),
new TwigSimpleFunction('placeholder', [$this, 'placeholderFunction'], ['is_safe' => ['html']]),
new TwigSimpleFunction('vite', [$this, 'viteFunction'], $options),
new TwigSimpleFunction('mix', [$this, 'mixFunction'], $options),
];
}

Expand Down Expand Up @@ -176,6 +178,11 @@ public function viteFunction(array $entrypoints, string $package): \Illuminate\S
return Vite::tags($entrypoints, $package);
}

public function mixFunction(array|string $paths, string $package, ?string $manifestPath = null): \Illuminate\Support\HtmlString|string
{
return Mix::tags($paths, $package, $manifestPath);
}

/**
* Opens a layout block.
*/
Expand Down
240 changes: 240 additions & 0 deletions modules/system/classes/asset/Mix.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

namespace System\Classes\Asset;

use Exception;
use Illuminate\Support\Facades\App;
use Illuminate\Support\HtmlString;
use InvalidArgumentException;
use Winter\Storm\Exception\SystemException;
use Winter\Storm\Support\Collection;
use Winter\Storm\Support\Facades\Url;
use Winter\Storm\Support\Str;

class Mix
{
/**
* The preloaded assets.
*
* @var array
*/
protected array $preloadedAssets = [];

/**
* The cached manifest files.
*
* @var array
*/
protected static array $manifests = [];

/**
* Get the preloaded assets.
*
* @return array
*/
public function preloadedAssets(): array
{
return $this->preloadedAssets;
}

/**
* Generate Mix tags for an entrypoint(s).
*
* @param array|string $entrypoints The list of entry points for Mix
* @param string|null $package The package name of the plugin or theme
* @param string|null $manifestPath
* @return HtmlString|string
* @throws SystemException
*/
public function __invoke(array|string $entrypoints, ?string $package = null, ?string $manifestPath = null): HtmlString|string
{
if (!$package) {
throw new InvalidArgumentException('A package must be passed');
}

// Normalise the package name
$package = strtolower($package);

$manifestPath ??= 'mix-manifest.json';

if (!($compilableAssetPackage = PackageManager::instance()->getPackages('mix')[$package] ?? null)) {
throw new SystemException('Unable to resolve package: ' . $package);
}

$manifestPath = public_path($compilableAssetPackage['path'] . Str::start($manifestPath, '/'));

if (!isset(static::$manifests[$manifestPath])) {
if (!is_file($manifestPath)) {
throw new Exception("The Mix manifest does not exist.");
}

static::$manifests[$manifestPath] = json_decode(file_get_contents($manifestPath), true);
}

$manifest = static::$manifests[$manifestPath];

$entrypoints = collect($entrypoints)
->map(fn($path) => Str::start($path, '/'));

$tags = collect();
$preloads = collect();

foreach ($entrypoints as $entrypoint) {
if (!isset($manifest[$entrypoint])) {
throw new Exception("Unable to locate file in Mix manifest: $entrypoint");
}

$preloads->push([
$entrypoint,
Url::asset($compilableAssetPackage['path'] . $manifest[$entrypoint]),
]);

$tags->push($this->makeTagForEntrypoint($entrypoint, Url::asset($compilableAssetPackage['path'] . $manifest[$entrypoint])));
}

[$stylesheets, $scripts] = $tags->unique()->partition(fn($tag) => str_starts_with($tag, '<link'));

$preloads = $preloads->unique()
->sortByDesc(fn($args) => $this->isCssPath($args[0]))
->map(fn($args) => $this->makePreloadTagForEntrypoint(...$args));

return new HtmlString($preloads->join('') . $stylesheets->join('') . $scripts->join(''));
}

/**
* Helper method to generate Mix tags for an entrypoint(s).
*
* @param array|string $entrypoints The list of entry points for Mix
* @param string $package The package name of the plugin or theme
* @param string|null $manifestPath The relative path to the mix-manifest.json file from the package path
* @return HtmlString|string
*
* @throws SystemException
*/
public static function tags(array|string $entrypoints, string $package, ?string $manifestPath = null): HtmlString|string
{
return App::make(static::class)($entrypoints, $package, $manifestPath);
}

/**
* Make a preload tag for the given entrypoint.
*
* @param $src
* @param $url
* @return string
*/
protected function makePreloadTagForEntrypoint($src, $url): string
{
$attributes = $this->resolvePreloadTagAttributes($src, $url);

$this->preloadedAssets[$url] = $this->parseAttributes(
Collection::make($attributes)->forget('href')->all()
);

return '<link ' . implode(' ', $this->parseAttributes($attributes)) . ' />';
}

/**
* Make tag for the given entrypoint.
*
* @param $src
* @param $url
* @return string
*/
protected function makeTagForEntrypoint($src, $url): string
{
return $this->makeTag($src, $url);
}

/**
* Generate an appropriate tag for the given URL.
*
* @param string $src
* @param string $url
* @return string
*/
protected function makeTag(string $src, string $url): string
{
if ($this->isCssPath($src)) {
return $this->makeStylesheetTagWithAttributes($url, []);
}

return $this->makeScriptTagWithAttributes($url, []);
}

/**
* Generate a link tag with attributes for the given URL.
*
* @param string $url
* @param array $attributes
* @return string
*/
protected function makeStylesheetTagWithAttributes(string $url, array $attributes): string
{
$attributes = $this->parseAttributes(array_merge([
'rel' => 'stylesheet',
'href' => $url,
], $attributes));

return '<link ' . implode(' ', $attributes) . ' />';
}

/**
* Generate a script tag with attributes for the given URL.
*
* @param string $url
* @param array $attributes
* @return string
*/
protected function makeScriptTagWithAttributes(string $url, array $attributes): string
{
$attributes = $this->parseAttributes(array_merge([
'src' => $url,
], $attributes));

return '<script ' . implode(' ', $attributes) . '></script>';
}

/**
* Determines whether the given path is a CSS file.
*
* @param string $path
* @return bool
*/
protected function isCssPath(string $path): bool
{
return Str::endsWith($path, '.css');
}

/**
* Resolve the attributes for the entrypoints generated preload tag.
*
* @param string $src
* @param string $url
* @return array
*/
protected function resolvePreloadTagAttributes(string $src, string $url): array
{
return [
'rel' => 'preload',
'as' => $this->isCssPath($src) ? 'style' : 'script',
'href' => $url,
];
}

/**
* Parse the attributes into key="value" strings.
*
* @param array $attributes
* @return array
*/
protected function parseAttributes(array $attributes): array
{
return Collection::make($attributes)
->reject(fn($value, $key) => in_array($value, [false, null], true))
->flatMap(fn($value, $key) => $value === true ? [$key] : [$key => $value])
->map(fn($value, $key) => is_int($key) ? $value : $key . '="' . $value . '"')
->values()
->all();
}
}
Loading
Loading