Implement development mode minification with AssetMinifier class
Co-authored-by: jmiller-moko <230051081+jmiller-moko@users.noreply.github.com>
This commit is contained in:
@@ -17,7 +17,7 @@ TPL_MOKO-CASSIOPEIA_XML_DESCRIPTION="<h3>MOKO-CASSIOPEIA Template Description</h
|
||||
|
||||
; ===== System / layout =====
|
||||
TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_LABEL="Development Mode"
|
||||
TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_DESC="If 'Development Mode' is active, certain features may be disabled, such as Google Tag Manager and Google Analytics."
|
||||
TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_DESC="When enabled, uses non-minified CSS/JS assets for easier debugging. When disabled, automatically creates and uses minified versions for better performance. Switching modes will automatically create or delete .min files as needed."
|
||||
TPL_MOKO-CASSIOPEIA_FLUID_LABEL="Layout"
|
||||
TPL_MOKO-CASSIOPEIA_STATIC="Static"
|
||||
TPL_MOKO-CASSIOPEIA_FLUID="Fluid"
|
||||
|
||||
@@ -17,7 +17,7 @@ TPL_MOKO-CASSIOPEIA_XML_DESCRIPTION="<h3>MOKO-CASSIOPEIA Template Description</h
|
||||
|
||||
; ===== System / layout =====
|
||||
TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_LABEL="Development Mode"
|
||||
TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_DESC="If 'Development Mode' is active, certain features may be disabled, such as Google Tag Manager and Google Analytics."
|
||||
TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_DESC="When enabled, uses non-minified CSS/JS assets for easier debugging. When disabled, automatically creates and uses minified versions for better performance. Switching modes will automatically create or delete .min files as needed."
|
||||
TPL_MOKO-CASSIOPEIA_FLUID_LABEL="Layout"
|
||||
TPL_MOKO-CASSIOPEIA_STATIC="Static"
|
||||
TPL_MOKO-CASSIOPEIA_FLUID="Fluid"
|
||||
|
||||
195
src/templates/AssetMinifier.php
Normal file
195
src/templates/AssetMinifier.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
/* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: Joomla.Template.Site
|
||||
INGROUP: Moko-Cassiopeia
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: ./templates/moko-cassiopeia/AssetMinifier.php
|
||||
VERSION: 03.05.00
|
||||
BRIEF: Asset minification helper for development mode toggle
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
/**
|
||||
* Asset Minifier Helper
|
||||
*
|
||||
* Handles minification and cleanup of CSS and JavaScript assets
|
||||
* based on the development mode setting.
|
||||
*/
|
||||
class AssetMinifier
|
||||
{
|
||||
/**
|
||||
* Minify CSS content
|
||||
*
|
||||
* @param string $css CSS content to minify
|
||||
* @return string Minified CSS
|
||||
*/
|
||||
public static function minifyCSS(string $css): string
|
||||
{
|
||||
// Remove comments
|
||||
$css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css);
|
||||
|
||||
// Remove whitespace
|
||||
$css = str_replace(["\r\n", "\r", "\n", "\t", ' ', ' ', ' '], '', $css);
|
||||
|
||||
// Remove spaces around selectors and properties
|
||||
$css = preg_replace('/\s*([{}|:;,])\s*/', '$1', $css);
|
||||
|
||||
// Remove trailing semicolons
|
||||
$css = str_replace(';}', '}', $css);
|
||||
|
||||
return trim($css);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minify JavaScript content
|
||||
*
|
||||
* @param string $js JavaScript content to minify
|
||||
* @return string Minified JavaScript
|
||||
*/
|
||||
public static function minifyJS(string $js): string
|
||||
{
|
||||
// Remove single-line comments (but preserve URLs)
|
||||
$js = preg_replace('~//[^\n]*\n~', "\n", $js);
|
||||
|
||||
// Remove multi-line comments
|
||||
$js = preg_replace('~/\*.*?\*/~s', '', $js);
|
||||
|
||||
// Remove whitespace
|
||||
$js = preg_replace('/\s+/', ' ', $js);
|
||||
|
||||
// Remove spaces around operators and punctuation
|
||||
$js = preg_replace('/\s*([\{\}\[\]\(\);,=<>!&|+\-*\/])\s*/', '$1', $js);
|
||||
|
||||
return trim($js);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create minified version of a file
|
||||
*
|
||||
* @param string $sourcePath Path to source file
|
||||
* @param string $destPath Path to minified file
|
||||
* @return bool Success status
|
||||
*/
|
||||
public static function minifyFile(string $sourcePath, string $destPath): bool
|
||||
{
|
||||
if (!file_exists($sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($sourcePath);
|
||||
if ($content === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ext = pathinfo($sourcePath, PATHINFO_EXTENSION);
|
||||
|
||||
if ($ext === 'css') {
|
||||
$minified = self::minifyCSS($content);
|
||||
} elseif ($ext === 'js') {
|
||||
$minified = self::minifyJS($content);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return file_put_contents($destPath, $minified) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all minified files in a directory (recursive)
|
||||
*
|
||||
* @param string $dir Directory path
|
||||
* @return int Number of files deleted
|
||||
*/
|
||||
public static function deleteMinifiedFiles(string $dir): int
|
||||
{
|
||||
$deleted = 0;
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && preg_match('/\.min\.(css|js)$/', $file->getFilename())) {
|
||||
if (unlink($file->getPathname())) {
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process assets based on development mode
|
||||
*
|
||||
* @param string $mediaPath Path to media directory
|
||||
* @param bool $developmentMode Development mode flag
|
||||
* @return array Status information
|
||||
*/
|
||||
public static function processAssets(string $mediaPath, bool $developmentMode): array
|
||||
{
|
||||
$result = [
|
||||
'mode' => $developmentMode ? 'development' : 'production',
|
||||
'minified' => 0,
|
||||
'deleted' => 0,
|
||||
'errors' => []
|
||||
];
|
||||
|
||||
if (!is_dir($mediaPath)) {
|
||||
$result['errors'][] = "Media path does not exist: {$mediaPath}";
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($developmentMode) {
|
||||
// Delete all .min files
|
||||
$result['deleted'] = self::deleteMinifiedFiles($mediaPath);
|
||||
} else {
|
||||
// Create minified versions of CSS and JS files
|
||||
$files = [
|
||||
'css/template.css' => 'css/template.min.css',
|
||||
'css/user.css' => 'css/user.min.css',
|
||||
'css/editor.css' => 'css/editor.min.css',
|
||||
'css/colors/light/colors_standard.css' => 'css/colors/light/colors_standard.min.css',
|
||||
'css/colors/light/colors_alternative.css' => 'css/colors/light/colors_alternative.min.css',
|
||||
'css/colors/light/colors_custom.css' => 'css/colors/light/colors_custom.min.css',
|
||||
'css/colors/dark/colors_standard.css' => 'css/colors/dark/colors_standard.min.css',
|
||||
'css/colors/dark/colors_alternative.css' => 'css/colors/dark/colors_alternative.min.css',
|
||||
'css/colors/dark/colors_custom.css' => 'css/colors/dark/colors_custom.min.css',
|
||||
'js/template.js' => 'js/template.min.js',
|
||||
'js/theme-init.js' => 'js/theme-init.min.js',
|
||||
'js/darkmode-toggle.js' => 'js/darkmode-toggle.min.js',
|
||||
'js/gtm.js' => 'js/gtm.min.js',
|
||||
];
|
||||
|
||||
foreach ($files as $source => $dest) {
|
||||
$sourcePath = $mediaPath . '/' . $source;
|
||||
$destPath = $mediaPath . '/' . $dest;
|
||||
|
||||
// Only minify if source exists and dest doesn't exist or is older
|
||||
if (file_exists($sourcePath)) {
|
||||
if (!file_exists($destPath) || filemtime($sourcePath) > filemtime($destPath)) {
|
||||
if (self::minifyFile($sourcePath, $destPath)) {
|
||||
$result['minified']++;
|
||||
} else {
|
||||
$result['errors'][] = "Failed to minify: {$source}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/** @var Joomla\CMS\Document\HtmlDocument $this */
|
||||
|
||||
// Load Asset Minifier
|
||||
require_once __DIR__ . '/AssetMinifier.php';
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$input = $app->getInput();
|
||||
$wa = $this->getWebAssetManager();
|
||||
@@ -41,6 +44,10 @@ $params_custom_head_start = $this->params->get('custom_head_start', null);
|
||||
$params_custom_head_end = $this->params->get('custom_head_end', null);
|
||||
$params_developmentmode = $this->params->get('developmentmode', false);
|
||||
|
||||
// Process assets based on development mode
|
||||
$mediaPath = JPATH_ROOT . '/media/templates/site/moko-cassiopeia';
|
||||
AssetMinifier::processAssets($mediaPath, $params_developmentmode);
|
||||
|
||||
// Bootstrap behaviors (assets handled via WAM)
|
||||
HTMLHelper::_('bootstrap.framework');
|
||||
HTMLHelper::_('bootstrap.loadCss', true);
|
||||
@@ -80,16 +87,19 @@ $this->setTitle($final);
|
||||
// Template/Media path
|
||||
$templatePath = 'media/templates/site/moko-cassiopeia';
|
||||
|
||||
// Asset suffix based on development mode
|
||||
$assetSuffix = $params_developmentmode ? '' : '.min';
|
||||
|
||||
// ===========================
|
||||
// Web Asset Manager (WAM) — matches your joomla.asset.json
|
||||
// ===========================
|
||||
|
||||
// Core template CSS
|
||||
$wa->useStyle('template.global.base'); // css/template.css
|
||||
$wa->useStyle('template.global.social-media-demo'); // css/user.css
|
||||
$wa->useStyle('template.global.base' . $assetSuffix); // css/template.css or template.min.css
|
||||
$wa->useStyle('template.user' . $assetSuffix); // css/user.css or user.min.css
|
||||
|
||||
// Optional vendor CSS
|
||||
$wa->useStyle('vendor.bootstrap-toc');
|
||||
$wa->useStyle('vendor.bootstrap-toc' . $assetSuffix);
|
||||
|
||||
// Optional demo/showcase CSS (available for use, not loaded by default)
|
||||
// To use: Add 'template.global.social-media-demo' to your article/module
|
||||
@@ -98,34 +108,34 @@ $wa->useStyle('vendor.bootstrap-toc');
|
||||
// Color theme (light + optional dark)
|
||||
$colorLightKey = strtolower(preg_replace('/[^a-z0-9_.-]/i', '', $params_LightColorName));
|
||||
$colorDarkKey = strtolower(preg_replace('/[^a-z0-9_.-]/i', '', $params_DarkColorName));
|
||||
$lightKey = 'template.light.' . $colorLightKey;
|
||||
$darkKey = 'template.dark.' . $colorDarkKey;
|
||||
$lightKey = 'template.light.' . $colorLightKey . $assetSuffix;
|
||||
$darkKey = 'template.dark.' . $colorDarkKey . $assetSuffix;
|
||||
try {
|
||||
$wa->useStyle('template.light.colors_standard');
|
||||
$wa->useStyle('template.light.colors_standard' . $assetSuffix);
|
||||
} catch (\Throwable $e) {
|
||||
$wa->registerAndUseStyle('template.light.colors_standard', $templatePath . '/css/global/light/colors_standard.css');
|
||||
$wa->registerAndUseStyle('template.light.colors_standard', $templatePath . '/css/colors/light/colors_standard' . $assetSuffix . '.css');
|
||||
}
|
||||
try {
|
||||
$wa->useStyle('template.dark.colors_standard');
|
||||
$wa->useStyle('template.dark.colors_standard' . $assetSuffix);
|
||||
} catch (\Throwable $e) {
|
||||
$wa->registerAndUseStyle('template.dark.colors_standard', $templatePath . '/css/global/dark/colors_standard.css');
|
||||
$wa->registerAndUseStyle('template.dark.colors_standard', $templatePath . '/css/colors/dark/colors_standard' . $assetSuffix . '.css');
|
||||
}
|
||||
try {
|
||||
$wa->useStyle($lightKey);
|
||||
} catch (\Throwable $e) {
|
||||
$wa->registerAndUseStyle('template.light.dynamic', $templatePath . '/css/global/light/' . $colorLightKey . '.css');
|
||||
$wa->registerAndUseStyle('template.light.dynamic', $templatePath . '/css/colors/light/' . $colorLightKey . $assetSuffix . '.css');
|
||||
}
|
||||
try {
|
||||
$wa->useStyle($darkKey);
|
||||
} catch (\Throwable $e) {
|
||||
$wa->registerAndUseStyle('template.dark.dynamic', $templatePath . '/css/global/dark/' . $colorDarkKey . '.css');
|
||||
$wa->registerAndUseStyle('template.dark.dynamic', $templatePath . '/css/colors/dark/' . $colorDarkKey . $assetSuffix . '.css');
|
||||
}
|
||||
|
||||
// Scripts
|
||||
$wa->useScript('template.js');
|
||||
$wa->useScript('theme-init.js');
|
||||
$wa->useScript('darkmode-toggle.js');
|
||||
$wa->useScript('vendor.bootstrap-toc.js');
|
||||
$wa->useScript('template.js' . $assetSuffix);
|
||||
$wa->useScript('theme-init' . $assetSuffix . '.js');
|
||||
$wa->useScript('darkmode-toggle' . $assetSuffix . '.js');
|
||||
$wa->useScript('vendor.bootstrap-toc.js' . $assetSuffix);
|
||||
|
||||
// Font scheme (external or local) + CSS custom properties
|
||||
$params_FontScheme = $this->params->get('useFontScheme', false);
|
||||
@@ -205,40 +215,21 @@ if ($this->params->get('faKitCode')) {
|
||||
HTMLHelper::_('script', $faKit, ['crossorigin' => 'anonymous']);
|
||||
} else {
|
||||
try {
|
||||
if($params_developmentmode){
|
||||
$wa->useStyle('vendor.fa7free.all');
|
||||
$wa->useStyle('vendor.fa7free.brands');
|
||||
$wa->useStyle('vendor.fa7free.fontawesome');
|
||||
$wa->useStyle('vendor.fa7free.regular');
|
||||
$wa->useStyle('vendor.fa7free.solid');
|
||||
} else {
|
||||
$wa->useStyle('vendor.fa7free.all.min');
|
||||
$wa->useStyle('vendor.fa7free.brands.min');
|
||||
$wa->useStyle('vendor.fa7free.fontawesome.min');
|
||||
$wa->useStyle('vendor.fa7free.regular.min');
|
||||
$wa->useStyle('vendor.fa7free.solid.min');
|
||||
}
|
||||
$wa->useStyle('vendor.fa7free.all' . $assetSuffix);
|
||||
$wa->useStyle('vendor.fa7free.brands' . $assetSuffix);
|
||||
$wa->useStyle('vendor.fa7free.fontawesome' . $assetSuffix);
|
||||
$wa->useStyle('vendor.fa7free.regular' . $assetSuffix);
|
||||
$wa->useStyle('vendor.fa7free.solid' . $assetSuffix);
|
||||
} catch (\Throwable $e) {
|
||||
if($params_developmentmode){
|
||||
$wa->registerAndUseStyle('vendor.fa7free.all.dynamic', $templatePath . '/vendor/fa7free/css/all.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.brands.dynamic', $templatePath . '/vendor/fa7free/css/brands.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.fontawesome.dynamic', $templatePath . '/vendor/fa7free/css/fontawesome.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.regular.dynamic', $templatePath . '/vendor/fa7free/css/regular.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.solid.dynamic', $templatePath . '/vendor/fa7free/css/solid.css');
|
||||
} else {
|
||||
$wa->registerAndUseStyle('vendor.fa7free.all.min.dynamic', $templatePath . '/vendor/fa7free/css/all.min.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.brands.min.dynamic', $templatePath . '/vendor/fa7free/css/brands.min.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.fontawesome.min.dynamic', $templatePath . '/vendor/fa7free/css/fontawesome.min.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.regular.min.dynamic', $templatePath . '/vendor/fa7free/css/regular.min.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.solid.min.dynamic', $templatePath . '/vendor/fa7free/css/solid.min.css');
|
||||
}
|
||||
|
||||
$wa->registerAndUseStyle('vendor.fa7free.all.dynamic', $templatePath . '/vendor/fa7free/css/all' . $assetSuffix . '.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.brands.dynamic', $templatePath . '/vendor/fa7free/css/brands' . $assetSuffix . '.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.fontawesome.dynamic', $templatePath . '/vendor/fa7free/css/fontawesome' . $assetSuffix . '.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.regular.dynamic', $templatePath . '/vendor/fa7free/css/regular' . $assetSuffix . '.css');
|
||||
$wa->registerAndUseStyle('vendor.fa7free.solid.dynamic', $templatePath . '/vendor/fa7free/css/solid' . $assetSuffix . '.css');
|
||||
}
|
||||
}
|
||||
$params_leftIcon = htmlspecialchars($this->params->get('drawerLeftIcon', 'fa-solid fa-chevron-left'), ENT_COMPAT, 'UTF-8');
|
||||
$params_rightIcon = htmlspecialchars($this->params->get('drawerRightIcon', 'fa-solid fa-chevron-right'), ENT_COMPAT, 'UTF-8');
|
||||
|
||||
$wa->useStyle('template.user'); // css/user.css
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
|
||||
|
||||
@@ -32,6 +32,9 @@ use Joomla\CMS\Uri\Uri;
|
||||
* @var string $this->direction
|
||||
*/
|
||||
|
||||
// Load Asset Minifier
|
||||
require_once __DIR__ . '/AssetMinifier.php';
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$doc = Factory::getDocument();
|
||||
$params = $this->params ?: $app->getTemplate(true)->params;
|
||||
@@ -40,15 +43,23 @@ $direction = $this->direction ?: 'ltr';
|
||||
/* -----------------------
|
||||
Load ONLY template.css + colors_*.css (with min toggle)
|
||||
------------------------ */
|
||||
$useMin = !((int) $params->get('development_mode', 0) === 1);
|
||||
$useMin = !((int) $params->get('developmentmode', 0) === 1);
|
||||
$assetSuffix = $useMin ? '.min' : '';
|
||||
$base = rtrim(Uri::root(true), '/') . '/templates/' . $this->template . '/css/';
|
||||
|
||||
// Process assets based on development mode
|
||||
$mediaPath = JPATH_ROOT . '/media/templates/site/moko-cassiopeia';
|
||||
AssetMinifier::processAssets($mediaPath, !$useMin);
|
||||
|
||||
$base = rtrim(Uri::root(true), '/') . '/media/templates/site/moko-cassiopeia/css/';
|
||||
|
||||
$doc->addStyleSheet($base . 'template' . $assetSuffix . '.css', ['version' => 'auto'], ['id' => 'moko-template']);
|
||||
/* If you have a template param for color variant, set it here; defaults to 'standard' */
|
||||
$colorKey = (string) ($params->get('colors', 'standard') ?: 'standard');
|
||||
$colorKey = (string) ($params->get('colorLightName', 'colors_standard') ?: 'colors_standard');
|
||||
$colorKey = preg_replace('~[^a-z0-9_-]~i', '', $colorKey);
|
||||
$doc->addStyleSheet($base . 'colors_' . $colorKey . $assetSuffix . '.css', ['version' => 'auto'], ['id' => 'moko-colors']);
|
||||
$doc->addStyleSheet($base . 'colors/light/' . $colorKey . $assetSuffix . '.css', ['version' => 'auto'], ['id' => 'moko-colors-light']);
|
||||
$colorKeyDark = (string) ($params->get('colorDarkName', 'colors_standard') ?: 'colors_standard');
|
||||
$colorKeyDark = preg_replace('~[^a-z0-9_-]~i', '', $colorKeyDark);
|
||||
$doc->addStyleSheet($base . 'colors/dark/' . $colorKeyDark . $assetSuffix . '.css', ['version' => 'auto'], ['id' => 'moko-colors-dark']);
|
||||
|
||||
/* Bootstrap CSS/JS for accordion behavior; safe to keep. */
|
||||
HTMLHelper::_('bootstrap.loadCss', true, $doc);
|
||||
|
||||
@@ -87,12 +87,10 @@
|
||||
<fields name="params">
|
||||
<!-- Advanced tab (non-theme/system options only) -->
|
||||
<fieldset name="advanced">
|
||||
<!--
|
||||
<field name="developmentmode" type="radio" label="TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_LABEL" description="TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_DESC" default="1" layout="joomla.form.field.radio.switcher" filter="boolean">
|
||||
<field name="developmentmode" type="radio" label="TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_LABEL" description="TPL_MOKO-CASSIOPEIA_DEVELOPMENTMODE_DESC" default="0" layout="joomla.form.field.radio.switcher" filter="boolean">
|
||||
<option value="0">JNO</option>
|
||||
<option value="1">JYES</option>
|
||||
</field>
|
||||
-->
|
||||
<field name="fluidContainer" type="radio" layout="joomla.form.field.radio.switcher" default="0" label="TPL_MOKO-CASSIOPEIA_FLUID_LABEL">
|
||||
<option value="0">TPL_MOKO-CASSIOPEIA_STATIC</option>
|
||||
<option value="1">TPL_MOKO-CASSIOPEIA_FLUID</option>
|
||||
|
||||
Reference in New Issue
Block a user