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
8 changes: 6 additions & 2 deletions docs/commands_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,20 @@ bin/magento mageforge:template:override 'Magento_Catalog::product/view/details.p
bin/magento mageforge:template:override vendor/magento/module-catalog/view/frontend/templates/product/view/details.phtml -t Vendor/theme
bin/magento mageforge:template:override 'Magento_Sales::order/new.html' --theme Vendor/theme
bin/magento mageforge:template:override 'Magento_Theme::css/source/_module.less' --theme Vendor/theme
bin/magento mageforge:template:override vendor/magento/module-catalog/view/frontend/templates --theme Vendor/theme
```

**Arguments:**

- `template` — The view file to override. Accepts the `Module_Name::path/to/file.phtml`
- `template` — The view file or directory to override. Accepts the `Module_Name::path/to/file.phtml`
notation or a file path (absolute, or relative to the Magento root / current directory).
File paths may point into a module's `view/<area>/templates`, `view/<area>/email` or
`view/<area>/web` directory, a Hyvä compat module, or another theme's override directory.
Email templates (`.html`) and static view files (CSS, LESS, JS, images, fonts, ...) are
detected automatically and copied to `<theme>/<Module_Name>/email/` or
`<theme>/<Module_Name>/web/` respectively.
A directory path (e.g. `vendor/magento/module-catalog/view/frontend/templates`) copies
all files inside it, including subdirectories.

**Options:**

Expand All @@ -174,7 +177,8 @@ bin/magento mageforge:template:override 'Magento_Theme::css/source/_module.less'
- After copying, the command re-resolves the file to verify the new override wins and cleans
the relevant caches (`full_page`, `block_html`, `layout`, `translate`).
- If the file is already overridden in the target theme, nothing is copied unless `--force`
is given.
is given. When copying a directory, already overridden files are skipped and the rest is
copied; with `--force` they are replaced as well.

---

Expand Down
135 changes: 134 additions & 1 deletion src/Console/Command/Template/OverrideCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ protected function configure(): void
->addArgument(
'template',
InputArgument::OPTIONAL,
'Template to override (Module_Name::path/to/template.phtml or a file path)',
'Template to override (Module_Name::path/to/template.phtml, a file path or a directory path)',
)
->addOption('theme', 't', InputOption::VALUE_REQUIRED, 'Target theme code (format: Vendor/theme)')
->addOption(
Expand Down Expand Up @@ -138,6 +138,19 @@ protected function executeCommand(InputInterface $input, OutputInterface $output
));
return Cli::RETURN_FAILURE;
}

if ($reference->isDirectory()) {
return $this->overrideDirectory(
$reference,
$theme,
$fallbackDirs,
$targetDir,
$isDryRun,
$isForce,
$output,
);
}

$targetFile = $targetDir . '/' . $templatePath;

$sourceFile = $this->fallbackResolver->findFirstExistingFile($fallbackDirs, $templatePath);
Expand Down Expand Up @@ -215,6 +228,124 @@ protected function executeCommand(InputInterface $input, OutputInterface $output
return Cli::RETURN_SUCCESS;
}

/**
* Copy all files of a template directory into the theme
*
* Every file is resolved through the fallback chain like a single template, so the copied
* files are the ones Magento currently uses. Files that are already overridden are skipped
* unless --force is given.
*
* @param TemplateReference $reference
* @param ThemeInterface $theme
* @param string[] $fallbackDirs
* @param string $targetDir
* @param bool $isDryRun
* @param bool $isForce
* @param OutputInterface $output
* @return int
*/
private function overrideDirectory(
TemplateReference $reference,
ThemeInterface $theme,
array $fallbackDirs,
string $targetDir,
bool $isDryRun,
bool $isForce,
OutputInterface $output,
): int {
$subPath = $reference->getTemplatePath();
$sources = $this->fallbackResolver->findDirectoryFiles($fallbackDirs, $subPath, $targetDir);
$overridden = $this->fallbackResolver->findDirectoryFiles([$targetDir], $subPath);
if ($sources === [] && $overridden === []) {
$this->io->error(sprintf('No files found in any fallback location for %s.', $reference->getTemplateId()));
$this->displayFallbackDirs($fallbackDirs, null, $targetDir, $subPath);
return Cli::RETURN_FAILURE;
}

$targetBase = $subPath === '' ? $targetDir : $targetDir . '/' . $subPath;
$copies = [];
foreach ($sources as $relativePath => $sourceFile) {
// Numeric file names become integer array keys
$relativePath = (string) $relativePath;
if (isset($overridden[$relativePath]) && !$isForce) {
continue;
}
$templatePath = $subPath === '' ? $relativePath : $subPath . '/' . $relativePath;
$copies[] = [
'path' => $templatePath,
'source' => $sourceFile,
'target' => $targetDir . '/' . $templatePath,
];
}
// Files that only exist in the theme have no other source to copy from
$skipped = count($overridden) - ($isForce ? count(array_intersect_key($overridden, $sources)) : 0);

$skippedNote = match (true) {
$skipped === 0 => '',
$isForce => ' (not present elsewhere in the fallback chain)',
default => ' (skipped, use --force to replace)',
};
$this->io->newLine();
$this->io->definitionList(
['Directory' => $reference->getTemplateId()],
['Theme' => $theme->getCode()],
['Target' => $this->toRelativePath($targetBase)],
['Files to copy' => (string) count($copies)],
['Already overridden' => $skipped . $skippedNote],
);

if ($copies === []) {
$this->io->info('All files of this directory are already overridden in this theme.');
return Cli::RETURN_SUCCESS;
}

if ($isDryRun || $this->isVerbose($output)) {
$this->io->table(['Source', 'Target'], array_map(fn(array $copy) => [
$this->toRelativePath($copy['source']),
$this->toRelativePath($copy['target']),
], $copies));
}

if ($isDryRun) {
$this->io->success('Dry run: no files were copied.');
return Cli::RETURN_SUCCESS;
}

$unresolved = [];
foreach ($copies as $copy) {
$templatePath = $copy['path'];
$sourceModuleName = $this->resolveSourceModuleName($copy['source'], $fallbackDirs, $reference);
$this->templateCopier->copy($copy['source'], $copy['target'], $sourceModuleName);

// Verify that Magento's fallback now resolves the file to the new override
$resolved = $this->fallbackResolver->findFirstExistingFile($fallbackDirs, $templatePath);
if ($resolved !== $copy['target']) {
$unresolved[] = $templatePath;
}
}

if ($unresolved !== []) {
$list = implode("\n - ", $unresolved);
$this->io->warning(
"The files were copied, but Magento does not resolve these to the new override:\n - {$list}",
);
return Cli::RETURN_FAILURE;
}

if (!$this->cacheCleaner->clean($this->io, $this->isVerbose($output))) {
$this->io->warning(sprintf(
'%d file(s) copied to %s, but cleaning the caches failed. '
. "Run 'bin/magento cache:clean full_page block_html layout translate' manually.",
count($copies),
$this->toRelativePath($targetBase),
));
return Cli::RETURN_FAILURE;
}

$this->io->success(sprintf('%d file(s) copied to %s', count($copies), $this->toRelativePath($targetBase)));
return Cli::RETURN_SUCCESS;
}

/**
* Get the template input from the argument or an interactive prompt
*
Expand Down Expand Up @@ -454,6 +585,8 @@ private function displayUsage(): void
);
$this->io->writeln('The template can also be given as a file path, e.g.');
$this->io->writeln(' vendor/magento/module-catalog/view/frontend/templates/product/view/details.phtml');
$this->io->writeln('Pass a directory path to copy all files inside it, e.g.');
$this->io->writeln(' vendor/magento/module-catalog/view/frontend/templates');
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/Model/TemplateReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ class TemplateReference
* @param string $moduleName Module name in Vendor_Module notation
* @param string $templatePath Path relative to the module's templates directory
* @param TemplateType $type Whether this is a block template or an email template
* @param bool $isDirectory Whether the reference points to a directory instead of a single file
*/
public function __construct(
private readonly string $moduleName,
private readonly string $templatePath,
private readonly TemplateType $type = TemplateType::TEMPLATE,
private readonly bool $isDirectory = false,
) {
}

Expand Down Expand Up @@ -51,6 +53,16 @@ public function getType(): TemplateType
return $this->type;
}

/**
* Check whether the reference points to a directory (all files inside are meant)
*
* @return bool
*/
public function isDirectory(): bool
{
return $this->isDirectory;
}

/**
* Get the full template identifier in Module_Name::path/to/template.phtml notation
*
Expand Down
41 changes: 41 additions & 0 deletions src/Service/TemplateOverride/TemplateFallbackResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,47 @@ public function findFirstExistingFile(
return null;
}

/**
* Collect all files below a directory across the fallback directories
*
* For every relative file path, the first fallback directory containing it wins, exactly like
* for a single template. The optional excluded directory (the override target) is not searched.
*
* @param string[] $fallbackDirs
* @param string $subPath Directory path relative to the fallback directories, empty for their root
* @param string|null $excludedDir
* @return array<int|string,string> Source file paths keyed by their path relative to $subPath
* @throws \Magento\Framework\Exception\FileSystemException
*/
public function findDirectoryFiles(array $fallbackDirs, string $subPath, ?string $excludedDir = null): array
{
$files = [];
foreach ($fallbackDirs as $dir) {
if ($dir === $excludedDir) {
continue;
}

$base = $subPath === '' ? $dir : $dir . '/' . $subPath;
if (!$this->fileDriver->isDirectory($base)) {
continue;
}

$prefix = rtrim(str_replace('\\', '/', $base), '/') . '/';
foreach ($this->fileDriver->readDirectoryRecursively($base) as $entry) {
$entry = str_replace('\\', '/', $entry);
if (!str_starts_with($entry, $prefix) || !$this->fileDriver->isFile($entry)) {
continue;
}

$files[substr($entry, strlen($prefix))] ??= $entry;
}
}

ksort($files, SORT_STRING);

return $files;
}

/**
* Get the fallback directory located within the given theme itself (the override target)
*
Expand Down
Loading
Loading