Skip to content
Closed
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
29 changes: 29 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,39 @@ bin/magento mageforge:theme:list
bin/magento mageforge:theme:build [<themeCodes>...]
```

**Examples**:

```bash
# Build a specific theme
bin/magento mageforge:theme:build Magento/luma

# Build multiple themes
bin/magento mageforge:theme:build Magento/luma Vendor/custom

# Interactive mode - select themes from list
bin/magento mageforge:theme:build

# Example output when theme not found:
$ bin/magento mageforge:theme:build vendor/Nema
[ERROR] Theme vendor/Nema is not installed.
Did you mean:
- Vendor/name
- Vendor/theme
- Vendor/custom
```

**Implementation Details**:

- If no theme codes are provided, displays an interactive prompt to select themes
- For each selected theme:
1. Resolves the theme path
2. Determines the appropriate builder for the theme type
3. Executes the build process
- **Theme Suggestion**: If a theme is not found, suggests similar theme names using:
- Levenshtein distance algorithm for typo detection
- Case-insensitive matching for case errors
- Substring matching for partial matches
- Shows up to 3 most similar theme names
- Displays a summary of built themes and execution time
- Has an alias: `frontend:build`

Expand Down Expand Up @@ -88,6 +114,7 @@ bin/magento mageforge:theme:watch [--theme=THEME]

- If no theme code is provided, displays an interactive prompt to select a theme
- Resolves the theme path
- **Theme Suggestion**: If a theme is not found, suggests similar theme names using fuzzy matching
- Determines the appropriate builder for the theme type
- Starts a watch process that monitors for file changes
- Has an alias: `frontend:watch`
Expand Down Expand Up @@ -353,6 +380,7 @@ bin/magento mageforge:hyva:tokens Hyva/default

- If no theme code is provided, displays an interactive prompt to select a HyvΓ€ theme
- Validates that the theme is installed and is a HyvΓ€ theme
- **Theme Suggestion**: If a theme is not found, suggests similar theme names using fuzzy matching
- Checks if the theme has been built (node_modules exists)
- Changes to the theme's `web/tailwind` directory
- Executes `npx hyva-tokens` to generate design tokens
Expand Down Expand Up @@ -406,6 +434,7 @@ The commands rely on several services for their functionality:

- `ThemeList`: Retrieves all installed themes
- `ThemePath`: Resolves theme codes to filesystem paths
- `ThemeSuggestion`: Suggests similar theme names when a theme is not found using fuzzy matching algorithms
- `StaticContentDeployer`: Handles static content deployment
- `CacheCleaner`: Manages cache cleaning after theme builds

Expand Down
46 changes: 41 additions & 5 deletions src/Console/Command/Theme/BuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use OpenForgeProject\MageForge\Model\ThemeList;
use OpenForgeProject\MageForge\Model\ThemePath;
use OpenForgeProject\MageForge\Service\ThemeBuilder\BuilderPool;
use OpenForgeProject\MageForge\Service\ThemeSuggestion;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
Expand All @@ -29,11 +30,13 @@ class BuildCommand extends AbstractCommand
* @param ThemePath $themePath
* @param ThemeList $themeList
* @param BuilderPool $builderPool
* @param ThemeSuggestion $themeSuggestion
*/
public function __construct(
private readonly ThemePath $themePath,
private readonly ThemeList $themeList,
private readonly BuilderPool $builderPool
private readonly BuilderPool $builderPool,
private readonly ThemeSuggestion $themeSuggestion
) {
parent::__construct();
}
Expand Down Expand Up @@ -165,9 +168,10 @@ private function processBuildThemes(
$themeNameCyan = sprintf("<fg=cyan>%s</>", $themeCode);
$spinner = new Spinner(sprintf("Building %s (%d of %d) ...", $themeNameCyan, $currentTheme, $totalThemes));
$success = false;
$errorInfo = null;

$spinner->spin(function() use ($themeCode, $io, $output, $isVerbose, &$successList, &$success) {
$success = $this->processTheme($themeCode, $io, $output, $isVerbose, $successList);
$spinner->spin(function() use ($themeCode, $io, $output, $isVerbose, &$successList, &$success, &$errorInfo) {
$success = $this->processTheme($themeCode, $io, $output, $isVerbose, $successList, $errorInfo);
return true;
});

Expand All @@ -177,6 +181,17 @@ private function processBuildThemes(
} else {
// Show that an error occurred while building the theme
$io->writeln(sprintf(" Building %s (%d of %d) ... <fg=red>failed</>", $themeNameCyan, $currentTheme, $totalThemes));

// Display error info if available
if ($errorInfo !== null) {
$io->error($errorInfo['message'] ?? "Unknown error occurred.");
if (!empty($errorInfo['suggestions'])) {
$io->writeln('<comment>Did you mean:</comment>');
foreach ($errorInfo['suggestions'] as $suggestion) {
$io->writeln(sprintf(' - <fg=cyan>%s</>', $suggestion));
}
}
}
}
}
}
Expand All @@ -194,19 +209,40 @@ private function processBuildThemes(
* @param OutputInterface $output
* @param bool $isVerbose
* @param array $successList
* @param array|null $errorInfo Reference to store error information for display outside spinner
* @return bool
*/
private function processTheme(
string $themeCode,
SymfonyStyle $io,
OutputInterface $output,
bool $isVerbose,
array &$successList
array &$successList,
?array &$errorInfo = null
): bool {
// Get theme path
$themePath = $this->themePath->getPath($themeCode);
if ($themePath === null) {
$io->error("Theme $themeCode is not installed.");
$errorMessage = "Theme $themeCode is not installed.";

// Suggest similar theme names
$suggestions = $this->themeSuggestion->getSuggestions($themeCode);

// If errorInfo is provided (non-verbose mode), store for later display
if ($errorInfo !== null) {
$errorInfo['message'] = $errorMessage;
$errorInfo['suggestions'] = $suggestions;
} else {
// Verbose mode: display immediately
$io->error($errorMessage);
if (!empty($suggestions)) {
$io->writeln('<comment>Did you mean:</comment>');
foreach ($suggestions as $suggestion) {
$io->writeln(sprintf(' - <fg=cyan>%s</>', $suggestion));
}
}
}

return false;
}

Expand Down
13 changes: 13 additions & 0 deletions src/Console/Command/Theme/TokensCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use OpenForgeProject\MageForge\Model\ThemeList;
use OpenForgeProject\MageForge\Model\ThemePath;
use OpenForgeProject\MageForge\Service\ThemeBuilder\BuilderPool;
use OpenForgeProject\MageForge\Service\ThemeSuggestion;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
Expand All @@ -27,13 +28,15 @@
* @param BuilderPool $builderPool
* @param File $fileDriver
* @param Shell $shell
* @param ThemeSuggestion $themeSuggestion
*/
public function __construct(
private readonly ThemeList $themeList,
private readonly ThemePath $themePath,
private readonly BuilderPool $builderPool,
private readonly File $fileDriver,
private readonly Shell $shell,
private readonly ThemeSuggestion $themeSuggestion,
) {
parent::__construct();
}
Expand All @@ -56,109 +59,119 @@
/**
* {@inheritdoc}
*/
protected function executeCommand(InputInterface $input, OutputInterface $output): int
{
$themeCode = $input->getArgument('themeCode');
$isVerbose = $this->isVerbose($output);

if (empty($themeCode)) {
$themes = $this->themeList->getAllThemes();
$options = array_map(fn($theme) => $theme->getCode(), $themes);

$themeCodePrompt = new SelectPrompt(
label: 'Select theme to generate tokens for',
options: $options,
scroll: 10,
hint: 'Arrow keys to navigate, Enter to confirm',
);

try {
$themeCode = $themeCodePrompt->prompt();
\Laravel\Prompts\Prompt::terminal()->restoreTty();
} catch (\Exception $e) {
$this->io->error('Interactive mode failed: ' . $e->getMessage());
return Cli::RETURN_FAILURE;
}
}

$themePath = $this->themePath->getPath($themeCode);
if ($themePath === null) {
$this->io->error("Theme $themeCode is not installed.");

// Suggest similar theme names
$suggestions = $this->themeSuggestion->getSuggestions($themeCode);
if (!empty($suggestions)) {
$this->io->writeln('<comment>Did you mean:</comment>');
foreach ($suggestions as $suggestion) {
$this->io->writeln(sprintf(' - <fg=cyan>%s</>', $suggestion));
}
}

return Cli::RETURN_FAILURE;
}

// Check if this is a HyvΓ€ theme
$builder = $this->builderPool->getBuilder($themePath);
if ($builder === null || $builder->getName() !== 'HyvaThemes') {
$this->io->error("Theme $themeCode is not a HyvΓ€ theme. This command only works with HyvΓ€ themes.");
return Cli::RETURN_FAILURE;
}

$tailwindPath = rtrim($themePath, '/') . '/web/tailwind';
if (!$this->fileDriver->isDirectory($tailwindPath)) {
$this->io->error("Tailwind directory not found in: $tailwindPath");
return Cli::RETURN_FAILURE;
}

// Check if node_modules exists
if (!$this->fileDriver->isDirectory($tailwindPath . '/node_modules')) {
$this->io->warning('Node modules not found. Please run: bin/magento mageforge:theme:build ' . $themeCode);
return Cli::RETURN_FAILURE;
}

if ($isVerbose) {
$this->io->section("Generating HyvΓ€ design tokens for theme: $themeCode");
$this->io->text("Working directory: $tailwindPath");
}

// Change to tailwind directory and run npx hyva-tokens
$currentDir = getcwd();
chdir($tailwindPath);

try {
if ($isVerbose) {
$this->io->text('Running npx hyva-tokens...');
}

$this->shell->execute('npx hyva-tokens');

chdir($currentDir);

// Determine output path based on theme location
$isVendorTheme = str_contains($themePath, '/vendor/');
$sourceFilePath = $tailwindPath . '/generated/hyva-tokens.css';

if ($isVendorTheme) {
// Store in var/generated/hyva-token/{ThemeCode}/ for vendor themes
$varGeneratedPath = $currentDir . '/var/generated/hyva-token/' . str_replace('/', '/', $themeCode);

if (!$this->fileDriver->isDirectory($varGeneratedPath)) {
$this->fileDriver->createDirectory($varGeneratedPath, 0755);
}

$generatedFilePath = $varGeneratedPath . '/hyva-tokens.css';

// Copy file to var/generated location
if ($this->fileDriver->isExists($sourceFilePath)) {
$this->fileDriver->copy($sourceFilePath, $generatedFilePath);
}

$this->io->success('HyvΓ€ design tokens generated successfully.');
$this->io->note('This is a vendor theme. Tokens have been saved to var/generated/hyva-token/ instead.');
$this->io->text('Generated file: ' . $generatedFilePath);
} else {
$generatedFilePath = $sourceFilePath;
$this->io->success('HyvΓ€ design tokens generated successfully.');
$this->io->text('Generated file: ' . $generatedFilePath);
}

$this->io->newLine();

return Cli::RETURN_SUCCESS;
} catch (\Exception $e) {
chdir($currentDir);
$this->io->error('Failed to generate HyvΓ€ design tokens: ' . $e->getMessage());
return Cli::RETURN_FAILURE;
}
}

Check notice on line 176 in src/Console/Command/Theme/TokensCommand.php

View check run for this annotation

codefactor.io / CodeFactor

src/Console/Command/Theme/TokensCommand.php#L62-L176

Complex Method
}
13 changes: 13 additions & 0 deletions src/Console/Command/Theme/WatchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use OpenForgeProject\MageForge\Model\ThemeList;
use OpenForgeProject\MageForge\Model\ThemePath;
use OpenForgeProject\MageForge\Service\ThemeBuilder\BuilderPool;
use OpenForgeProject\MageForge\Service\ThemeSuggestion;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
Expand All @@ -23,11 +24,13 @@ class WatchCommand extends AbstractCommand
* @param BuilderPool $builderPool
* @param ThemeList $themeList
* @param ThemePath $themePath
* @param ThemeSuggestion $themeSuggestion
*/
public function __construct(
private readonly BuilderPool $builderPool,
private readonly ThemeList $themeList,
private readonly ThemePath $themePath,
private readonly ThemeSuggestion $themeSuggestion,
) {
parent::__construct();
}
Expand Down Expand Up @@ -86,6 +89,16 @@ protected function executeCommand(InputInterface $input, OutputInterface $output
$themePath = $this->themePath->getPath($themeCode);
if ($themePath === null) {
$this->io->error("Theme $themeCode is not installed.");

// Suggest similar theme names
$suggestions = $this->themeSuggestion->getSuggestions($themeCode);
if (!empty($suggestions)) {
$this->io->writeln('<comment>Did you mean:</comment>');
foreach ($suggestions as $suggestion) {
$this->io->writeln(sprintf(' - <fg=cyan>%s</>', $suggestion));
}
}

return self::FAILURE;
}

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

declare(strict_types=1);

namespace OpenForgeProject\MageForge\Service;

use OpenForgeProject\MageForge\Model\ThemeList;

/**
* Service for suggesting similar theme names
*/
class ThemeSuggestion
{
/**
* @param ThemeList $themeList
*/
public function __construct(
private readonly ThemeList $themeList
) {
}

/**
* Find the most similar theme names to the input
*
* @param string $input The theme name that was not found
* @param int $maxSuggestions Maximum number of suggestions to return
* @return array Array of suggested theme codes
*/
public function getSuggestions(string $input, int $maxSuggestions = 3): array
{
$themes = $this->themeList->getAllThemes();
$suggestions = [];

foreach ($themes as $theme) {
$themeCode = $theme->getCode();
$similarity = $this->calculateSimilarity($input, $themeCode);

if ($similarity > 0) {
$suggestions[] = [
'code' => $themeCode,
'similarity' => $similarity
];
}
}

// Sort by similarity (highest first)
usort($suggestions, function ($a, $b) {
return $b['similarity'] <=> $a['similarity'];
});

// Return only the theme codes, limited by maxSuggestions
return array_slice(
array_column($suggestions, 'code'),
0,
$maxSuggestions
);
}

/**
* Calculate similarity between two strings
* Uses a combination of Levenshtein distance and case-insensitive comparison
*
* @param string $input User input
* @param string $target Theme code to compare against
* @return float Similarity score (higher is more similar)
*/
private function calculateSimilarity(string $input, string $target): float
{
// Normalize inputs for comparison
$inputLower = strtolower($input);
$targetLower = strtolower($target);

// Exact match (case-insensitive) gets highest score
if ($inputLower === $targetLower) {
return 100.0;
}

// Check if input is a substring of target
if (str_contains($targetLower, $inputLower)) {
return 90.0 + (strlen($inputLower) / strlen($targetLower)) * 5;
}

// Calculate Levenshtein distance
$distance = levenshtein($inputLower, $targetLower);

// If distance is too large, consider it not similar
$maxLength = max(strlen($inputLower), strlen($targetLower));
if ($distance > $maxLength * 0.6) {
return 0.0;
}

// Convert distance to similarity score (0-80 range)
$similarity = (1 - ($distance / $maxLength)) * 80;

return max(0.0, $similarity);
}
}