diff --git a/.gitignore b/.gitignore
index 70316a2746..1d18db58f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,10 @@
/files/
/modules/
/themes/
+/composer-addons/modules/*
+!/composer-addons/modules/.gitkeep
+/composer-addons/themes/*
+!/composer-addons/themes/.gitkeep
/application/test/config/database.ini
/application/test/.phpunit.result.cache
/application/asset/css/font-awesome/
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 683c2201c5..0177b6ff4c 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -50,6 +50,7 @@
->exclude('application/data/doctrine-proxies')
->exclude('application/data/media-types')
->exclude('application/data/overrides')
+ ->exclude('composer-addons')
->exclude('config')
->exclude('files')
->exclude('modules')
diff --git a/application/config/application.config.php b/application/config/application.config.php
index 1cdd31a32c..3f863cd936 100644
--- a/application/config/application.config.php
+++ b/application/config/application.config.php
@@ -32,6 +32,7 @@
'module_paths' => [
'Omeka' => OMEKA_PATH . '/application',
OMEKA_PATH . '/modules',
+ OMEKA_PATH . '/composer-addons/modules',
],
'config_glob_paths' => [
OMEKA_PATH . '/config/local.config.php',
diff --git a/application/data/composer-addon-installer/composer.json b/application/data/composer-addon-installer/composer.json
index d3f7ccc9e6..89d5b81024 100644
--- a/application/data/composer-addon-installer/composer.json
+++ b/application/data/composer-addon-installer/composer.json
@@ -1,13 +1,17 @@
{
"name": "omeka/composer-addon-installer",
- "version": "2.0",
+ "version": "3.0",
+ "description": "Composer plugin to install Omeka S modules and themes into composer-addons/modules/ and composer-addons/themes/.",
"type": "composer-plugin",
"license": "GPL-3.0",
"require": {
+ "php": ">=8.1",
"composer-plugin-api": "^2.0"
},
"autoload": {
- "psr-4": {"Omeka\\Composer\\": "src/"}
+ "psr-4": {
+ "Omeka\\Composer\\": "src/"
+ }
},
"extra": {
"class": "Omeka\\Composer\\AddonInstallerPlugin"
diff --git a/application/data/composer-addon-installer/src/AddonInstaller.php b/application/data/composer-addon-installer/src/AddonInstaller.php
index b77480f4fc..de8e9da834 100644
--- a/application/data/composer-addon-installer/src/AddonInstaller.php
+++ b/application/data/composer-addon-installer/src/AddonInstaller.php
@@ -4,17 +4,38 @@
use Composer\Package\PackageInterface;
use Composer\Installer\LibraryInstaller;
+/**
+ * Composer installer for Omeka S modules and themes.
+ *
+ * Installs packages to composer-addons/modules/ or composer-addons/themes/ based on type.
+ * Name transformations align with composer/installers OmekaSInstaller.
+ *
+ * Supports extra options:
+ * - installer-name: Explicit install name (overrides auto-detection)
+ */
class AddonInstaller extends LibraryInstaller
{
/**
- * Gets the name this package is to be installed with, either from the
- *
extra.install-name
property or the package name.
+ * Gets the name this package is to be installed with.
+ *
+ * Priority:
+ * 1. extra.installer-name (explicit)
+ * 2. Auto-transformation based on package type
+ *
+ * For modules: removes prefixes/suffixes and converts to CamelCase
+ * For themes: removes prefixes/suffixes, keeps lowercase with hyphens
*
* @return string
*/
- public static function getInstallName(PackageInterface $package)
+ public static function getInstallName(PackageInterface $package): string
{
$extra = $package->getExtra();
+
+ // Support both installer-name (composer/installers) and install-name
+ // (legacy).
+ if (isset($extra['installer-name'])) {
+ return $extra['installer-name'];
+ }
if (isset($extra['install-name'])) {
return $extra['install-name'];
}
@@ -22,28 +43,85 @@ public static function getInstallName(PackageInterface $package)
$packageName = $package->getPrettyName();
$slashPos = strpos($packageName, '/');
if ($slashPos === false) {
- throw new \InvalidArgumentException('Addon package names must contain a slash');
+ throw new \InvalidArgumentException('Add-on package names must contain a slash'); // @translate
}
- $addonName = substr($packageName, $slashPos + 1);
- return $addonName;
+ $name = substr($packageName, $slashPos + 1);
+ $type = $package->getType();
+
+ if ($type === 'omeka-s-module') {
+ return static::inflectModuleName($name);
+ }
+
+ if ($type === 'omeka-s-theme') {
+ return static::inflectThemeName($name);
+ }
+
+ return $name;
+ }
+
+ /**
+ * Transform module name: remove prefixes/suffixes, convert to CamelCase.
+ *
+ * Examples:
+ * - omeka-s-module-common → Common
+ * - value-suggest → ValueSuggest
+ * - bulk-import-module → BulkImport
+ * - neatline-omeka-s → Neatline
+ * - module-lessonplans → Lessonplans
+ *
+ * @param string $name
+ * @return string
+ */
+ protected static function inflectModuleName($name): string
+ {
+ // Remove Omeka prefixes/suffixes.
+ $name = preg_replace('/^(omeka-?s?-?)?(module-)?/', '', $name);
+ $name = preg_replace('/(-module)?(-omeka-?s?)?$/', '', $name);
+
+ // Convert kebab-case to CamelCase.
+ $name = strtr($name, ['-' => ' ']);
+ $name = strtr(ucwords($name), [' ' => '']);
+
+ return $name;
}
- public function getInstallPath(PackageInterface $package)
+ /**
+ * Transform theme name: remove prefixes/suffixes, keep lowercase.
+ *
+ * Examples:
+ * - omeka-s-theme-repository → repository
+ * - my-custom-theme → my-custom
+ * - flavor-theme-omeka → flavor
+ *
+ * @param string $name
+ * @return string
+ */
+ protected static function inflectThemeName($name): string
+ {
+ // Remove Omeka prefixes/suffixes.
+ $name = preg_replace('/^(omeka-?s?-?)?(theme-)?/', '', $name);
+ $name = preg_replace('/(-theme)?(-omeka-?s?)?$/', '', $name);
+
+ return $name;
+ }
+
+ public function getInstallPath(PackageInterface $package): string
{
$addonName = static::getInstallName($package);
switch ($package->getType()) {
- case 'omeka-s-theme':
- return 'themes/' . $addonName;
case 'omeka-s-module':
- return 'modules/' . $addonName;
+ return 'composer-addons/modules/' . $addonName;
+ case 'omeka-s-theme':
+ return 'composer-addons/themes/' . $addonName;
default:
- throw new \InvalidArgumentException('Invalid Omeka S addon package type');
+ throw new \InvalidArgumentException('Invalid Omeka S add-on package type'); // @translate
}
}
- public function supports($packageType)
+ public function supports($packageType): bool
{
- return in_array($packageType, ['omeka-s-theme', 'omeka-s-module']);
+ return $packageType === 'omeka-s-module'
+ || $packageType === 'omeka-s-theme';
}
}
diff --git a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php
index 8a98e7c2be..5899d83e6d 100644
--- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php
+++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php
@@ -6,6 +6,11 @@
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
+/**
+ * Composer plugin for Omeka S add-on installation.
+ *
+ * Registers the AddonInstaller for modules and themes.
+ */
class AddonInstallerPlugin implements PluginInterface
{
public function activate(Composer $composer, IOInterface $io)
diff --git a/application/data/scripts/install-addon-deps.php b/application/data/scripts/install-addon-deps.php
new file mode 100644
index 0000000000..ada90d9914
--- /dev/null
+++ b/application/data/scripts/install-addon-deps.php
@@ -0,0 +1,142 @@
+#!/usr/bin/env php
+ $version) {
+ // Skip Omeka core packages (provided by Omeka)
+ if (in_array($package, ['omeka/omeka-s', 'omeka/omeka-s-core'])) {
+ continue;
+ }
+ // Skip PHP extensions
+ if (strpos($package, 'ext-') === 0) {
+ continue;
+ }
+ // Skip PHP version constraint
+ if ($package === 'php') {
+ continue;
+ }
+ $toInstall[$package] = $version;
+}
+
+if (empty($toInstall)) {
+ echo "No external dependencies to install for $addonName.\n";
+ exit(0);
+}
+
+echo "Dependencies for $addonName:\n";
+foreach ($toInstall as $package => $version) {
+ echo " - $package: $version\n";
+}
+
+if ($dryRun) {
+ echo "\n[Dry run] Would run:\n";
+ echo " composer require " . implode(' ', array_keys($toInstall)) . "\n";
+ exit(0);
+}
+
+echo "\nInstalling...\n";
+
+$packages = implode(' ', array_map(function ($pkg, $ver) {
+ // Use version constraint if specific, otherwise let composer decide
+ if (preg_match('/^\^|~|>=|<=|>|<|\*/', $ver)) {
+ return escapeshellarg("$pkg:$ver");
+ }
+ return escapeshellarg($pkg);
+}, array_keys($toInstall), $toInstall));
+
+$command = "cd " . escapeshellarg(dirname(__DIR__, 3)) . " && composer require $packages --no-interaction 2>&1";
+
+echo "Running: composer require " . implode(' ', array_keys($toInstall)) . "\n\n";
+
+passthru($command, $exitCode);
+
+exit($exitCode);
diff --git a/application/src/Module/AbstractModule.php b/application/src/Module/AbstractModule.php
index 34d7103460..c602e8d856 100644
--- a/application/src/Module/AbstractModule.php
+++ b/application/src/Module/AbstractModule.php
@@ -125,7 +125,7 @@ public function getAutoloaderConfig()
return;
}
- $autoloadPath = sprintf('%1$s/modules/%2$s/src', OMEKA_PATH, $namespace);
+ $autoloadPath = dirname($classInfo->getFileName()) . '/src';
return [
'Laminas\Loader\StandardAutoloader' => [
'namespaces' => [
diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php
new file mode 100644
index 0000000000..38df03902b
--- /dev/null
+++ b/application/src/Module/InfoReader.php
@@ -0,0 +1,500 @@
+ composer.json > module.ini/theme.ini > defaults.
+ *
+ * For performance, installed.json is loaded once and cached. It contains
+ * metadata for all Composer-installed packages, avoiding individual file reads.
+ *
+ * Note: installed.json is used instead of installed.php because installed.php
+ * only contains minimal data (version, type, install_path) without the `extra`
+ * keys needed for add-on metadata (label, etc.).
+ */
+class InfoReader
+{
+ /**
+ * Generic keywords to filter out from tags.
+ */
+ protected const GENERIC_KEYWORDS = [
+ 'omeka',
+ 'omeka s',
+ 'omeka-s',
+ 'omeka s module',
+ 'omeka module',
+ 'module',
+ 'omeka s theme',
+ 'omeka theme',
+ 'theme',
+ ];
+
+ /**
+ * Regex patterns to remove common prefixes from project names.
+ */
+ protected const PREFIX_PATTERN = '/^(omeka-?s?-?)?(module-|theme-)?/i';
+
+ /**
+ * Regex patterns to remove common suffixes from project names.
+ */
+ protected const SUFFIX_PATTERN = '/(-module|-theme)?(-omeka-?s?)?$/i';
+
+ /**
+ * Cache of installed.json data, indexed by add-on directory name.
+ *
+ * @var array|null
+ */
+ protected static $installedAddons = null;
+
+ /**
+ * Mapping from composer.json to ini keys.
+ *
+ * @var array
+ */
+ protected $composerToIniMap = [
+ // composer.json key => ini key
+ 'description' => 'description',
+ 'license' => 'license',
+ // theme_link is managed below.
+ 'homepage' => 'module_link',
+ 'version' => 'version',
+ ];
+
+ /**
+ * Mapping from composer.json extra keys to ini keys.
+ *
+ * @var array
+ */
+ protected $extraToIniMap = [
+ 'label' => 'name',
+ ];
+
+ /**
+ * Read info from composer.json and ini file.
+ *
+ * @param string $path The module/theme directory path
+ * @param string $type 'module' or 'theme'
+ * @return array|null Returns merged info array, or null if no valid source
+ * found.
+ */
+ public function read(string $path, string $type = 'module'): ?array
+ {
+ $composerInfo = $this->readComposerJson($path);
+ $iniInfo = $this->readIniFile($path, $type);
+
+ // At least one source must exist.
+ if ($composerInfo === null && $iniInfo === null) {
+ return null;
+ }
+
+ return $this->merge($composerInfo, $iniInfo, $path);
+ }
+
+ /**
+ * Check if the info is valid (has required fields).
+ */
+ public function isValid(?array $info): bool
+ {
+ if (!$info) {
+ return false;
+ }
+
+ // Required field: name only.
+ // Version can be derived from composer or defaults to 1.0.0.
+ if (empty($info['name'])) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Read and parse composer.json file.
+ */
+ protected function readComposerJson(string $path): ?array
+ {
+ $file = $path . '/composer.json';
+ if (!is_file($file) || !is_readable($file)) {
+ return null;
+ }
+
+ $content = file_get_contents($file);
+ $json = json_decode($content, true);
+ return is_array($json)
+ ? $json
+ : null;
+ }
+
+ /**
+ * Read and parse ini file.
+ *
+ * @param string $type "module" or "theme".
+ */
+ protected function readIniFile(string $path, string $type): ?array
+ {
+ $file = $path . '/config/' . $type . '.ini';
+ if (!is_file($file) || !is_readable($file)) {
+ return null;
+ }
+
+ $iniReader = new IniReader();
+ try {
+ $ini = $iniReader->fromFile($file);
+ } catch (\Exception $e) {
+ return null;
+ }
+
+ return $ini['info'] ?? null;
+ }
+
+ /**
+ * Merge composer.json and ini info, with composer.json taking precedence.
+ */
+ protected function merge(?array $composerJson, ?array $iniInfo, string $path): array
+ {
+ // Start with ini info as base.
+ $info = $iniInfo ?: [];
+
+ // If no composer.json, return ini info with defaults.
+ if (!$composerJson) {
+ return $this->applyDefaults($info, $path);
+ }
+
+ // Map package data to info, preserving existing ini values for optional
+ // fields.
+ $info = $this->mapPackageToInfo($composerJson, $info);
+
+ return $this->applyDefaults($info, $path, $composerJson);
+ }
+
+ /**
+ * Map composer package data to info array format.
+ *
+ * @param array $package The composer.json or installed.json package data
+ * @param array $existingInfo Optional existing info to preserve for fields
+ * not in package.
+ */
+ protected function mapPackageToInfo(array $package, array $existingInfo = []): array
+ {
+ $info = $existingInfo;
+ $extra = $package['extra'] ?? [];
+
+ // Map standard composer.json fields.
+ foreach ($this->composerToIniMap as $composerKey => $iniKey) {
+ if (isset($package[$composerKey]) && $package[$composerKey] !== '') {
+ $info[$iniKey] = $package[$composerKey];
+ }
+ }
+
+ // Map extra fields.
+ foreach ($this->extraToIniMap as $extraKey => $iniKey) {
+ if (isset($extra[$extraKey])) {
+ $info[$iniKey] = $extra[$extraKey];
+ }
+ }
+
+ // Map keywords to tags, filtering out generic keywords.
+ if (isset($package['keywords']) && is_array($package['keywords'])) {
+ $keywords = $this->filterGenericKeywords($package['keywords']);
+ if (count($keywords)) {
+ $info['tags'] = implode(', ', $keywords);
+ }
+ }
+
+ // Map first author.
+ if (isset($package['authors'][0])) {
+ $firstAuthor = $package['authors'][0];
+ if (isset($firstAuthor['name']) && !isset($info['author'])) {
+ $info['author'] = $firstAuthor['name'];
+ }
+ if (isset($firstAuthor['homepage']) && !isset($info['author_link'])) {
+ $info['author_link'] = $firstAuthor['homepage'];
+ }
+ }
+
+ // Map support issues link.
+ if (isset($package['support']['issues']) && !isset($info['support_link'])) {
+ $info['support_link'] = $package['support']['issues'];
+ }
+
+ // theme_link uses the same homepage as module_link.
+ if (isset($package['homepage']) && !isset($info['theme_link'])) {
+ $info['theme_link'] = $package['homepage'];
+ }
+
+ return $info;
+ }
+
+ /**
+ * Filter out generic keywords that don't add value as tags.
+ */
+ protected function filterGenericKeywords(array $keywords): array
+ {
+ return array_filter($keywords, function ($keyword) {
+ return !in_array(strtolower($keyword), self::GENERIC_KEYWORDS);
+ });
+ }
+
+ /**
+ * Apply default values for missing fields.
+ */
+ protected function applyDefaults(array $info, string $path, ?array $composerJson = null): array
+ {
+ $dirName = basename($path);
+
+ // Default name: use directory name if not set.
+ if (empty($info['name'])) {
+ // Try to get a nice name from composer project name
+ if ($composerJson !== null && isset($composerJson['name'])) {
+ $info['name'] = $this->projectNameToLabel($composerJson['name']);
+ } else {
+ // Convert CamelCase to "Camel Case"
+ $info['name'] = preg_replace('/([a-z])([A-Z])/', '$1 $2', $dirName);
+ }
+ }
+
+ // Default version: try composer installed data, then fallback.
+ if (empty($info['version'])) {
+ $composerVersion = $this->getVersionFromComposerInstalled($composerJson);
+ if ($composerVersion) {
+ $info['version'] = $composerVersion;
+ } else {
+ // According to composer, default version is 1.0.0.
+ $info['version'] = '1.0.0';
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * Try to get version from Composer's installed.json.
+ */
+ protected function getVersionFromComposerInstalled(?array $composerJson): ?string
+ {
+ if ($composerJson === null || !isset($composerJson['name'])) {
+ return null;
+ }
+
+ $packageName = $composerJson['name'];
+ $installedFile = OMEKA_PATH . '/vendor/composer/installed.json';
+
+ if (!is_file($installedFile) || !is_readable($installedFile)) {
+ return null;
+ }
+
+ $content = file_get_contents($installedFile);
+ $installed = json_decode($content, true);
+
+ if (!$installed) {
+ return null;
+ }
+
+ // Composer 2.x format: packages are in "packages" key.
+ $packages = $installed['packages'] ?? $installed;
+
+ foreach ($packages as $package) {
+ if (isset($package['name']) && $package['name'] === $packageName) {
+ $version = $package['version'] ?? null;
+ if ($version) {
+ // Remove 'v' prefix if present.
+ return ltrim($version, 'vV');
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the installer name (directory name) from composer.json.
+ */
+ public function getInstallerName(string $path): ?string
+ {
+ $composerJson = $this->readComposerJson($path);
+ if ($composerJson === null) {
+ return null;
+ }
+
+ // First check extra.installer-name.
+ if (isset($composerJson['extra']['installer-name'])) {
+ return $composerJson['extra']['installer-name'];
+ }
+
+ // Fall back to computed directory name from project name.
+ if (isset($composerJson['name'])) {
+ return $this->projectNameToDirectory($composerJson['name']);
+ }
+
+ return null;
+ }
+
+ /**
+ * Convert a composer project name to a human-readable label.
+ *
+ * Removes common prefixes and suffixes like AddonInstaller::inflect*.
+ *
+ * Example: "daniel-km/omeka-s-module-easy-admin" => "Easy Admin"
+ */
+ public function projectNameToLabel(string $projectName): string
+ {
+ $words = $this->projectNameToWords($projectName);
+ return implode(' ', $words);
+ }
+
+ /**
+ * Convert a composer project name to a directory name.
+ *
+ * Removes common prefixes and suffixes like AddonInstaller::inflect*.
+ *
+ * Example: "daniel-km/omeka-s-module-easy-admin" => "EasyAdmin"
+ */
+ public function projectNameToDirectory(string $projectName): string
+ {
+ $words = $this->projectNameToWords($projectName);
+ return implode('', $words);
+ }
+
+ /**
+ * Parse project name into clean words, removing omeka/module/theme noise.
+ *
+ * @return string[] Array of capitalized words
+ */
+ protected function projectNameToWords(string $projectName): array
+ {
+ // Extract project name after vendor/.
+ $parts = explode('/', $projectName);
+ $project = end($parts);
+
+ // Remove common prefixes and suffixes.
+ $project = preg_replace(self::PREFIX_PATTERN, '', $project);
+ $project = preg_replace(self::SUFFIX_PATTERN, '', $project);
+
+ // Split kebab-case and capitalize each word.
+ $words = explode('-', $project);
+ return array_map('ucfirst', $words);
+ }
+
+ /**
+ * Load installed.json once and cache add-on metadata indexed by dir name.
+ *
+ * This avoids reading individual composer.json files for composer-installed
+ * add-ons, significantly improving bootstrap performance.
+ */
+ public function loadComposerInstalled(): void
+ {
+ if (self::$installedAddons !== null) {
+ return;
+ }
+
+ self::$installedAddons = ['modules' => [], 'themes' => []];
+
+ $installedFile = OMEKA_PATH . '/vendor/composer/installed.json';
+ if (!is_file($installedFile) || !is_readable($installedFile)) {
+ return;
+ }
+
+ $content = file_get_contents($installedFile);
+ $installed = json_decode($content, true);
+ if (!$installed) {
+ return;
+ }
+
+ // Composer 2.x format: packages are in "packages" key.
+ $packages = $installed['packages'] ?? $installed;
+
+ foreach ($packages as $package) {
+ $type = $package['type'] ?? '';
+ if ($type === 'omeka-s-module') {
+ $key = 'modules';
+ } elseif ($type === 'omeka-s-theme') {
+ $key = 'themes';
+ } else {
+ continue;
+ }
+
+ $extra = $package['extra'] ?? [];
+
+ // Determine the directory name.
+ $dirName = $extra['installer-name']
+ ?? $this->projectNameToDirectory($package['name'] ?? '');
+
+ if (empty($dirName)) {
+ continue;
+ }
+
+ // Build the info array from installed.json data.
+ $info = $this->buildInfoFromPackage($package);
+
+ self::$installedAddons[$key][$dirName] = $info;
+ }
+ }
+
+ /**
+ * Get add-on info from installed.json cache.
+ *
+ * @param string $addonId The add-on directory name
+ * @param string $type 'module' or 'theme'
+ * @return array|null The add-on info or null if not found in installed.json.
+ */
+ public function getFromComposerInstalled(string $addonId, string $type = 'module'): ?array
+ {
+ $this->loadComposerInstalled();
+
+ $key = $type === 'module' ? 'modules' : 'themes';
+ return self::$installedAddons[$key][$addonId] ?? null;
+ }
+
+ /**
+ * Check if an add-on is installed via Composer.
+ *
+ * @param string $addonId The add-on directory name
+ * @param string $type 'module' or 'theme'
+ */
+ public function isComposerInstalled(string $addonId, string $type = 'module'): bool
+ {
+ return $this->getFromComposerInstalled($addonId, $type) !== null;
+ }
+
+ /**
+ * Build info array from a Composer package entry in installed.json.
+ */
+ protected function buildInfoFromPackage(array $package): array
+ {
+ $info = $this->mapPackageToInfo($package);
+
+ // Default name from project name.
+ if (empty($info['name'])) {
+ $info['name'] = $this->projectNameToLabel($package['name'] ?? '');
+ }
+
+ // Default version, with 'v' prefix removed.
+ if (empty($info['version'])) {
+ $version = $package['version'] ?? '1.0.0';
+ $info['version'] = ltrim($version, 'vV');
+ }
+
+ // Clean up description: remove common prefixes like "Module for Omeka S:".
+ if (!empty($info['description'])) {
+ $info['description'] = preg_replace(
+ '/^(Module|Theme)\s+for\s+Omeka\s*S?\s*:\s*/i',
+ '',
+ $info['description']
+ );
+ }
+
+ return $info;
+ }
+
+ /**
+ * Clear the installed.json cache, mainly for testing.
+ */
+ public static function clearCache(): void
+ {
+ self::$installedAddons = null;
+ }
+}
diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php
index cf483470d9..a562e4939d 100644
--- a/application/src/Service/ModuleManagerFactory.php
+++ b/application/src/Service/ModuleManagerFactory.php
@@ -8,9 +8,9 @@
use Composer\Semver\Semver;
use Interop\Container\ContainerInterface;
use Omeka\Module as CoreModule;
+use Omeka\Module\InfoReader;
use Omeka\Module\Manager as ModuleManager;
use SplFileInfo;
-use Laminas\Config\Reader\Ini as IniReader;
use Laminas\ServiceManager\Factory\FactoryInterface;
/**
@@ -27,54 +27,85 @@ class ModuleManagerFactory implements FactoryInterface
public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?array $options = null)
{
$manager = new ModuleManager($serviceLocator);
- $iniReader = new IniReader;
+ $infoReader = new InfoReader();
$connection = $serviceLocator->get('Omeka\Connection');
- // Get all modules from the filesystem.
- foreach (new DirectoryIterator(OMEKA_PATH . '/modules') as $dir) {
-
- // Module must be a directory
- if (!$dir->isDir() || $dir->isDot()) {
- continue;
- }
-
- $module = $manager->registerModule($dir->getBasename());
-
- // Module directory must contain config/module.ini
- $iniFile = new SplFileInfo($dir->getPathname() . '/config/module.ini');
- if (!$iniFile->isReadable() || !$iniFile->isFile()) {
- $module->setState(ModuleManager::STATE_INVALID_INI);
- continue;
- }
-
- $ini = $iniReader->fromFile($iniFile->getRealPath());
-
- // The INI configuration must be under the [info] header.
- if (!isset($ini['info'])) {
- $module->setState(ModuleManager::STATE_INVALID_INI);
- continue;
- }
-
- $module->setIni($ini['info']);
-
- // Module INI must be valid
- if (!$manager->iniIsValid($module)) {
- $module->setState(ModuleManager::STATE_INVALID_INI);
- continue;
- }
+ // Load installed.json once for all Composer-installed modules.
+ $infoReader->loadComposerInstalled();
- // Module directory must contain Module.php
- $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php');
- if (!$moduleFile->isReadable() || !$moduleFile->isFile()) {
- $module->setState(ModuleManager::STATE_INVALID_MODULE);
+ // Get all modules from the filesystem.
+ // Scan local modules first so they take precedence over add-ons.
+ // Note: composer-addons/modules/ is scanned even though installed.json contains
+ // the module list. This ensures Module.php exists, handles out-of-sync
+ // cases, and maintains consistency with how modules/ works. The
+ // installed.json is only used for metadata (name, version, etc.), not
+ // for discovering which modules are installed.
+ $modulePaths = [
+ OMEKA_PATH . '/modules',
+ OMEKA_PATH . '/composer-addons/modules',
+ ];
+ $registered = [];
+ foreach ($modulePaths as $modulePath) {
+ if (!is_dir($modulePath)) {
continue;
}
- $module->setModuleFilePath($moduleFile->getRealPath());
-
- $omekaConstraint = $module->getIni('omeka_version_constraint');
- if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) {
- $module->setState(ModuleManager::STATE_INVALID_OMEKA_VERSION);
- continue;
+ foreach (new DirectoryIterator($modulePath) as $dir) {
+
+ // Module must be a directory.
+ if (!$dir->isDir() || $dir->isDot()) {
+ continue;
+ }
+
+ // Skip if module already registered (local takes precedence).
+ $moduleId = $dir->getBasename();
+ if (isset($registered[$moduleId])) {
+ continue;
+ }
+ $registered[$moduleId] = true;
+
+ $module = $manager->registerModule($moduleId);
+
+ // Only use installed.json for modules in composer-addons/modules/.
+ // Local modules in modules/ must read their own files.
+ $info = null;
+ $isComposerAddon = strpos($dir->getPathname(), '/composer-addons/modules/') !== false;
+ if ($isComposerAddon) {
+ // Try installed.json first to avoid checking compatibility.
+ $info = $infoReader->getFromComposerInstalled($moduleId, 'module');
+ }
+ if (empty($info)) {
+ // Fallback: read from individual files (manual modules).
+ $info = $infoReader->read($dir->getPathname(), 'module');
+ }
+
+ // Module must have valid info.
+ if (!$infoReader->isValid($info)) {
+ $module->setState(ModuleManager::STATE_INVALID_INI);
+ continue;
+ }
+
+ // Check configurable from module.config.php (priority) or module.ini (fallback).
+ $info['configurable'] = $this->isModuleConfigurable($dir->getPathname(), $info);
+
+ $module->setIni($info);
+
+ // Module directory must contain Module.php.
+ $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php');
+ if (!$moduleFile->isReadable() || !$moduleFile->isFile()) {
+ $module->setState(ModuleManager::STATE_INVALID_MODULE);
+ continue;
+ }
+ $module->setModuleFilePath($moduleFile->getRealPath());
+
+ // Check Omeka version constraint only for manual modules.
+ // Composer add-ons use require.omeka/omeka-s for version checking.
+ if (!$isComposerAddon) {
+ $omekaConstraint = $module->getIni('omeka_version_constraint');
+ if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) {
+ $module->setState(ModuleManager::STATE_INVALID_OMEKA_VERSION);
+ continue;
+ }
+ }
}
}
@@ -123,7 +154,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar
}
// Module class must extend Omeka\Module\AbstractModule
- // (delay this check until here to avoid loading non-active module files)
+ // This check is delayed here to avoid loading non-active modules.
require_once $module->getModuleFilePath();
$moduleClass = $module->getId() . '\Module';
if (!class_exists($moduleClass)
@@ -133,13 +164,13 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar
continue;
}
- // Module valid, installed, and active
+ // Module valid, installed, and active.
$module->setState(ModuleManager::STATE_ACTIVE);
}
foreach ($manager->getModules() as $id => $module) {
if (!$module->getState()) {
- // Module in filesystem but not installed
+ // Module in filesystem but not installed.
$module->setState(ModuleManager::STATE_NOT_INSTALLED);
}
}
@@ -149,4 +180,43 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar
return $manager;
}
+
+ /**
+ * Determine if a module is configurable.
+ *
+ * Priority:
+ * 1. module.config.php ['module_config']['configurable']
+ * 2. module.ini 'configurable' (fallback, already in $info)
+ *
+ * Note: Some module.config.php files use self::CONSTANT which requires the
+ * module class context. We use error handling to gracefully skip those.
+ *
+ * @param string $modulePath Path to the module directory
+ * @param array $info Module info from InfoReader
+ * @return bool
+ */
+ protected function isModuleConfigurable(string $modulePath, array $info): bool
+ {
+ // Priority 1: Check module.config.php.
+ $configFile = $modulePath . '/config/module.config.php';
+ if (is_file($configFile) && is_readable($configFile)) {
+ // Convert errors to exceptions to catch undefined constants, etc.
+ set_error_handler(function ($severity, $message) {
+ throw new \ErrorException($message, 0, $severity);
+ });
+ try {
+ $config = include $configFile;
+ if (is_array($config) && isset($config['module_config']['configurable'])) {
+ restore_error_handler();
+ return (bool) $config['module_config']['configurable'];
+ }
+ } catch (\Throwable $e) {
+ // Config file uses module-specific constants or has errors, skip.
+ }
+ restore_error_handler();
+ }
+
+ // Priority 2: Fallback to module.ini (already read into $info).
+ return !empty($info['configurable']);
+ }
}
diff --git a/application/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php
index 6252eea7ed..1f0fb759f5 100644
--- a/application/src/Service/ThemeManagerFactory.php
+++ b/application/src/Service/ThemeManagerFactory.php
@@ -2,9 +2,9 @@
namespace Omeka\Service;
use DirectoryIterator;
-use SplFileInfo;
use Composer\Semver\Semver;
use Omeka\Module as CoreModule;
+use Omeka\Module\InfoReader;
use Omeka\Site\Theme\Manager as ThemeManager;
use Laminas\Config\Reader\Ini as IniReader;
use Laminas\ServiceManager\Factory\FactoryInterface;
@@ -20,69 +20,111 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar
$moduleBlockTemplates = $config['block_templates'];
$manager = new ThemeManager;
+ $infoReader = new InfoReader();
$iniReader = new IniReader;
- // Get all themes from the filesystem.
- foreach (new DirectoryIterator(OMEKA_PATH . '/themes') as $dir) {
-
- // Theme must be a directory
- if (!$dir->isDir() || $dir->isDot()) {
- continue;
- }
-
- $theme = $manager->registerTheme($dir->getBasename());
+ // Load installed.json once for all Composer-installed themes.
+ $infoReader->loadComposerInstalled();
- // Theme directory must contain config/module.ini
- $iniFile = new SplFileInfo($dir->getPathname() . '/config/theme.ini');
- if (!$iniFile->isReadable() || !$iniFile->isFile()) {
- $theme->setState(ThemeManager::STATE_INVALID_INI);
- continue;
- }
-
- $ini = $iniReader->fromFile($iniFile->getRealPath());
-
- // The INI configuration must be under the [info] header.
- if (!isset($ini['info'])) {
- $theme->setState(ThemeManager::STATE_INVALID_INI);
- continue;
- }
- $configSpec = [];
- if (isset($ini['config'])) {
- $configSpec = $ini['config'];
- }
-
- $theme->setIni($ini['info']);
- $theme->setConfigSpec($configSpec);
-
- // Theme INI must be valid
- if (!$manager->iniIsValid($theme)) {
- $theme->setState(ThemeManager::STATE_INVALID_INI);
- continue;
- }
-
- $omekaConstraint = $theme->getIni('omeka_version_constraint');
- if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) {
- $theme->setState(ThemeManager::STATE_INVALID_OMEKA_VERSION);
+ // Get all themes from the filesystem.
+ // Scan local themes first so they take precedence over add-ons.
+ // Note: composer-addons/themes/ is scanned even though installed.json contains
+ // the theme list. This ensures theme files exist, handles out-of-sync
+ // cases, and maintains consistency with how themes/ works. The
+ // installed.json is only used for metadata (name, version, etc.), not
+ // for discovering which themes are installed.
+ $themePaths = [
+ 'themes' => OMEKA_PATH . '/themes',
+ 'composer-addons/themes' => OMEKA_PATH . '/composer-addons/themes',
+ ];
+ $registered = [];
+ foreach ($themePaths as $basePath => $themePath) {
+ if (!is_dir($themePath)) {
continue;
}
-
- $theme->setState(ThemeManager::STATE_ACTIVE);
-
- // Inject module templates, with priority to theme templates.
- // Take care of merge with duplicate template keys.
- if (count($modulePageTemplates)) {
- $configSpec['page_templates'] = empty($configSpec['page_templates'])
- ? $modulePageTemplates
- : array_replace($modulePageTemplates, $configSpec['page_templates']);
- }
- if (count($moduleBlockTemplates)) {
- $configSpec['block_templates'] = empty($configSpec['block_templates'])
- ? $moduleBlockTemplates
- // Array_merge_recursive() converts duplicate keys to array.
- // Array_map() removes keys.
- : array_replace_recursive($moduleBlockTemplates, $configSpec['block_templates']);
+ foreach (new DirectoryIterator($themePath) as $dir) {
+
+ // Theme must be a directory
+ if (!$dir->isDir() || $dir->isDot()) {
+ continue;
+ }
+
+ // Skip if theme already registered (local takes precedence).
+ $themeId = $dir->getBasename();
+ if (isset($registered[$themeId])) {
+ continue;
+ }
+ $registered[$themeId] = true;
+
+ $theme = $manager->registerTheme($themeId);
+ $theme->setBasePath($basePath);
+
+ // Only use installed.json for themes in composer-addons/themes/.
+ // Local themes in themes/ must read their own files.
+ $info = null;
+ $isComposerAddon = strpos($dir->getPathname(), '/composer-addons/themes/') !== false;
+ if ($isComposerAddon) {
+ // Try installed.json first to avoid checking compatibility.
+ $info = $infoReader->getFromComposerInstalled($themeId, 'theme');
+ }
+ if (empty($info)) {
+ // Fallback: read from individual files (manual themes).
+ $info = $infoReader->read($dir->getPathname(), 'theme');
+ }
+
+ // Theme must have valid info.
+ if (!$infoReader->isValid($info)) {
+ $theme->setState(ThemeManager::STATE_INVALID_INI);
+ continue;
+ }
+
+ // Read config spec from theme.ini [config] section if present.
+ // This is always needed, even for Composer themes, as [config]
+ // defines form elements and is not in composer.json.
+ $configSpec = [];
+ $iniFile = $dir->getPathname() . '/config/theme.ini';
+ if (is_file($iniFile) && is_readable($iniFile)) {
+ try {
+ $ini = $iniReader->fromFile($iniFile);
+ if (isset($ini['config'])) {
+ $configSpec = $ini['config'];
+ }
+ } catch (\Exception $e) {
+ // Ignore ini read errors for config section.
+ }
+ }
+
+ $theme->setIni($info);
+ $theme->setConfigSpec($configSpec);
+
+ // Check Omeka version constraint only for manual themes.
+ // Composer add-ons use require.omeka/omeka-s for version checking.
+ if (!$isComposerAddon) {
+ $omekaConstraint = $theme->getIni('omeka_version_constraint');
+ if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) {
+ $theme->setState(ThemeManager::STATE_INVALID_OMEKA_VERSION);
+ continue;
+ }
+ }
+
+ $theme->setState(ThemeManager::STATE_ACTIVE);
+
+ // Inject module templates, with priority to theme templates.
+ // Take care of merge with duplicate template keys.
+ if (count($modulePageTemplates)) {
+ $configSpec['page_templates'] = empty($configSpec['page_templates'])
+ ? $modulePageTemplates
+ : array_replace($modulePageTemplates, $configSpec['page_templates']);
+ }
+ if (count($moduleBlockTemplates)) {
+ $configSpec['block_templates'] = empty($configSpec['block_templates'])
+ ? $moduleBlockTemplates
+ // Array_merge_recursive() converts duplicate keys to array.
+ // Array_map() removes keys.
+ : array_replace_recursive($moduleBlockTemplates, $configSpec['block_templates']);
+ }
+ $theme->setConfigSpec($configSpec);
}
- $theme->setConfigSpec($configSpec);
}
// Note that, unlike the ModuleManagerFactory, this does not register
diff --git a/application/src/Site/Theme/Theme.php b/application/src/Site/Theme/Theme.php
index 05c54703d7..fcf6660bd9 100644
--- a/application/src/Site/Theme/Theme.php
+++ b/application/src/Site/Theme/Theme.php
@@ -8,6 +8,11 @@ class Theme
*/
protected $id;
+ /**
+ * @var string
+ */
+ protected $basePath = 'themes';
+
/**
* @var string
*/
@@ -43,6 +48,26 @@ public function getId()
return $this->id;
}
+ /**
+ * Set the base path for this theme (relative to OMEKA_PATH).
+ *
+ * @param string $basePath e.g., 'composer-addons/themes' or 'themes'
+ */
+ public function setBasePath($basePath)
+ {
+ $this->basePath = $basePath;
+ }
+
+ /**
+ * Get the base path for this theme (relative to OMEKA_PATH).
+ *
+ * @return string
+ */
+ public function getBasePath()
+ {
+ return $this->basePath;
+ }
+
/**
* Set the theme state.
*
@@ -123,9 +148,10 @@ public function getSettingsKey()
public function getThumbnail($key = null)
{
if ($key) {
+ // For other themes, assume default path (no basePath info available).
return '/themes/' . $key . "/theme.jpg";
}
- return '/themes/' . $this->id . "/theme.jpg";
+ return '/' . $this->basePath . '/' . $this->id . "/theme.jpg";
}
/**
@@ -139,7 +165,7 @@ public function getThumbnail($key = null)
public function isConfigurable()
{
$configSpec = $this->getConfigSpec();
- return $configSpec && $configSpec['elements'];
+ return $configSpec && !empty($configSpec['elements']);
}
/**
@@ -162,7 +188,7 @@ public function isConfigurableResourcePageBlocks()
*/
public function getPath(string ...$subsegments): string
{
- $segments = array_merge([OMEKA_PATH, 'themes', $this->id], $subsegments);
+ $segments = array_merge([OMEKA_PATH, $this->basePath, $this->id], $subsegments);
return implode('/', $segments);
}
}
diff --git a/application/test/OmekaTest/Composer/AddonInstallerTest.php b/application/test/OmekaTest/Composer/AddonInstallerTest.php
new file mode 100644
index 0000000000..b60a1d47df
--- /dev/null
+++ b/application/test/OmekaTest/Composer/AddonInstallerTest.php
@@ -0,0 +1,83 @@
+markTestSkipped('Requires composer/composer dev dependency.');
+ }
+ parent::setUp();
+ }
+
+ /**
+ * @dataProvider moduleNameProvider
+ */
+ public function testInflectModuleName(string $packageName, string $expected): void
+ {
+ $package = $this->createMockPackage($packageName, 'omeka-s-module');
+ $result = AddonInstaller::getInstallName($package);
+ $this->assertEquals($expected, $result);
+ }
+
+ /**
+ * @dataProvider themeNameProvider
+ */
+ public function testInflectThemeName(string $packageName, string $expected): void
+ {
+ $package = $this->createMockPackage($packageName, 'omeka-s-theme');
+ $result = AddonInstaller::getInstallName($package);
+ $this->assertEquals($expected, $result);
+ }
+
+ public function testInstallerNameOverride(): void
+ {
+ $package = $this->createMockPackage('vendor/some-module', 'omeka-s-module', [
+ 'installer-name' => 'CustomName',
+ ]);
+ $result = AddonInstaller::getInstallName($package);
+ $this->assertEquals('CustomName', $result);
+ }
+
+ public function moduleNameProvider(): array
+ {
+ return [
+ ['vendor/value-suggest', 'ValueSuggest'],
+ ['vendor/omeka-s-module-common', 'Common'],
+ ['vendor/bulk-import-module', 'BulkImport'],
+ ['vendor/module-lessonplans-omeka-s', 'Lessonplans'],
+ ['vendor/neatline-omeka-s', 'Neatline'],
+ ['daniel-km/omeka-s-module-common', 'Common'],
+ ];
+ }
+
+ public function themeNameProvider(): array
+ {
+ return [
+ ['vendor/thanks-roy', 'thanks-roy'],
+ ['vendor/omeka-s-theme-repository', 'repository'],
+ ['vendor/my-custom-theme-theme', 'my-custom-theme'],
+ ['vendor/flavor-theme-omeka', 'flavor'],
+ ];
+ }
+
+ protected function createMockPackage(string $name, string $type, array $extra = [])
+ {
+ $package = $this->createMock(\Composer\Package\PackageInterface::class);
+ $package->method('getPrettyName')->willReturn($name);
+ $package->method('getType')->willReturn($type);
+ $package->method('getExtra')->willReturn($extra);
+ return $package;
+ }
+}
diff --git a/application/test/OmekaTest/Module/AutoloaderTest.php b/application/test/OmekaTest/Module/AutoloaderTest.php
new file mode 100644
index 0000000000..c3b29ce65e
--- /dev/null
+++ b/application/test/OmekaTest/Module/AutoloaderTest.php
@@ -0,0 +1,347 @@
+testModuleName = 'TestAutoloader_' . uniqid();
+ $this->localModulePath = OMEKA_PATH . '/modules/' . $this->testModuleName;
+ $this->addonModulePath = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName;
+ }
+
+ protected function tearDown(): void
+ {
+ // Clean up test modules.
+ $this->removeDirectory($this->localModulePath);
+ $this->removeDirectory($this->addonModulePath);
+
+ parent::tearDown();
+ }
+
+ protected function removeDirectory($path)
+ {
+ // Handle symlinks first.
+ if (is_link($path)) {
+ unlink($path);
+ return;
+ }
+ if (!is_dir($path)) {
+ return;
+ }
+ $files = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($files as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getRealPath());
+ } else {
+ unlink($file->getRealPath());
+ }
+ }
+ rmdir($path);
+ }
+
+ /**
+ * Create a test module with a service class.
+ */
+ protected function createTestModule($basePath, $source)
+ {
+ mkdir($basePath, 0755, true);
+ mkdir($basePath . '/src', 0755, true);
+
+ // Create Module.php.
+ $modulePhp = <<testModuleName};
+
+use Omeka\Module\AbstractModule;
+
+class Module extends AbstractModule
+{
+ const SOURCE = '$source';
+
+ public function getConfig()
+ {
+ return [];
+ }
+}
+PHP;
+ file_put_contents($basePath . '/Module.php', $modulePhp);
+
+ // Create a PSR-4 service class.
+ $servicePhp = <<testModuleName};
+
+class TestService
+{
+ const SOURCE = '$source';
+
+ public function getSource(): string
+ {
+ return self::SOURCE;
+ }
+}
+PHP;
+ file_put_contents($basePath . '/src/TestService.php', $servicePhp);
+
+ // Create config/module.ini.
+ mkdir($basePath . '/config', 0755, true);
+ $ini = "[info]\n";
+ $ini .= "name = \"{$this->testModuleName}\"\n";
+ $ini .= "version = \"1.0.0\"\n";
+ file_put_contents($basePath . '/config/module.ini', $ini);
+ }
+
+ /**
+ * Test that local module (modules/) takes precedence over composer-addons/modules/.
+ *
+ * This is the key test for the bootstrap autoloader. When a module exists
+ * in both locations with different content, the autoloader MUST load
+ * the class from modules/ (local) to allow local overrides.
+ */
+ public function testLocalModuleTakesPrecedenceOverAddons()
+ {
+ // Create module in both locations with different SOURCE values.
+ $this->createTestModule($this->addonModulePath, 'addon');
+ $this->createTestModule($this->localModulePath, 'local');
+
+ $className = $this->testModuleName . '\\TestService';
+
+ if (class_exists($className, false)) {
+ $this->markTestSkipped('Class already loaded from previous test.');
+ }
+
+ $this->assertTrue(class_exists($className, true), 'Class should be autoloadable');
+
+ // The class MUST be loaded from modules/ (local), not composer-addons/modules/.
+ $reflection = new \ReflectionClass($className);
+ $this->assertStringContainsString('/modules/' . $this->testModuleName, $reflection->getFileName(),
+ 'Class should be loaded from modules/, not composer-addons/modules/');
+ $this->assertStringNotContainsString('/composer-addons/', $reflection->getFileName(),
+ 'Class should NOT be loaded from composer-addons/modules/');
+ $this->assertEquals('local', $className::SOURCE,
+ 'Class SOURCE should be "local" to confirm modules/ takes precedence');
+ }
+
+ /**
+ * Test that symlink from modules/ to addons/ does NOT trigger override.
+ *
+ * When modules/Foo is a symlink to composer-addons/modules/Foo, the autoloader
+ * should NOT intervene (is_link check returns true), allowing Composer
+ * to handle the loading normally.
+ */
+ public function testSymlinkModuleDoesNotTriggerOverride()
+ {
+ // Create module in composer-addons/modules/.
+ $this->createTestModule($this->addonModulePath, 'addon');
+
+ // Create symlink from modules/ to composer-addons/modules/.
+ symlink($this->addonModulePath, $this->localModulePath);
+
+ $className = $this->testModuleName . '\\TestService';
+
+ if (class_exists($className, false)) {
+ $this->markTestSkipped('Class already loaded from previous test.');
+ }
+
+ // With symlink, is_link() returns true, so autoloader returns false.
+ // The class will only be loadable if Composer knows about it.
+ // Since our test module is not in Composer, it won't be loadable.
+ // This is expected behavior - the autoloader should NOT intervene for symlinks.
+
+ // We can verify the autoloader's logic by checking the conditions directly.
+ $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName;
+ $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName;
+
+ $this->assertTrue(is_dir($localModule), 'Local module path should exist (symlink)');
+ $this->assertTrue(is_link($localModule), 'Local module should be a symlink');
+ $this->assertTrue(is_dir($addonModule), 'Addon module path should exist');
+
+ // The autoloader condition: !is_dir($local) || is_link($local) || !is_dir($addon)
+ // For symlink: is_link($local) is true, so autoloader returns false (no override).
+ $autoloaderWouldIntervene = is_dir($localModule) && !is_link($localModule) && is_dir($addonModule);
+ $this->assertFalse($autoloaderWouldIntervene,
+ 'Autoloader should NOT intervene when local is a symlink');
+ }
+
+ /**
+ * Test autoloader does not intervene when module only exists in modules/.
+ */
+ public function testAutoloaderDoesNotInterveneForLocalOnlyModule()
+ {
+ // Create module only in modules/.
+ $this->createTestModule($this->localModulePath, 'local');
+
+ $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName;
+ $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName;
+
+ $this->assertTrue(is_dir($localModule), 'Local module should exist');
+ $this->assertFalse(is_dir($addonModule), 'Addon module should NOT exist');
+
+ // Autoloader condition check.
+ $autoloaderWouldIntervene = is_dir($localModule) && !is_link($localModule) && is_dir($addonModule);
+ $this->assertFalse($autoloaderWouldIntervene,
+ 'Autoloader should NOT intervene when module only exists in modules/');
+ }
+
+ /**
+ * Test autoloader does not intervene when module only exists in composer-addons/modules/.
+ */
+ public function testAutoloaderDoesNotInterveneForAddonOnlyModule()
+ {
+ // Create module only in composer-addons/modules/.
+ $this->createTestModule($this->addonModulePath, 'addon');
+
+ $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName;
+ $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName;
+
+ $this->assertFalse(is_dir($localModule), 'Local module should NOT exist');
+ $this->assertTrue(is_dir($addonModule), 'Addon module should exist');
+
+ // Autoloader condition check.
+ $autoloaderWouldIntervene = is_dir($localModule) && !is_link($localModule) && is_dir($addonModule);
+ $this->assertFalse($autoloaderWouldIntervene,
+ 'Autoloader should NOT intervene when module only exists in composer-addons/modules/');
+ }
+
+ /**
+ * Test partial override: only some classes exist in local module.
+ *
+ * When a module exists in both locations but the local version only contains
+ * some classes (partial override), the autoloader should:
+ * - Load existing classes from local
+ * - Return false for missing classes, allowing Composer to load from addon
+ */
+ public function testPartialOverrideAllowsFallbackToAddon()
+ {
+ // Create full module in composer-addons/modules/ with two classes.
+ $this->createTestModule($this->addonModulePath, 'addon');
+ $this->createAdditionalClass($this->addonModulePath, 'AnotherService', 'addon');
+
+ // Create partial override in modules/ with only TestService (not AnotherService).
+ $this->createTestModule($this->localModulePath, 'local');
+ // Note: AnotherService is NOT created in local.
+
+ // Verify both directories exist (autoloader will intervene).
+ $this->assertTrue(is_dir($this->localModulePath));
+ $this->assertTrue(is_dir($this->addonModulePath));
+
+ // Verify file existence.
+ $localTestService = $this->localModulePath . '/src/TestService.php';
+ $localAnotherService = $this->localModulePath . '/src/AnotherService.php';
+ $addonAnotherService = $this->addonModulePath . '/src/AnotherService.php';
+
+ $this->assertTrue(file_exists($localTestService), 'TestService should exist in local');
+ $this->assertFalse(file_exists($localAnotherService), 'AnotherService should NOT exist in local');
+ $this->assertTrue(file_exists($addonAnotherService), 'AnotherService should exist in addon');
+
+ // Test the autoloader logic directly (simulating what bootstrap.php does).
+ // For TestService: file exists in local -> would be loaded from local.
+ // For AnotherService: file doesn't exist in local -> autoloader returns false.
+ $testServiceClass = $this->testModuleName . '\\TestService';
+ $anotherServiceClass = $this->testModuleName . '\\AnotherService';
+
+ // Simulate autoloader logic for AnotherService.
+ $moduleNamespace = $this->testModuleName;
+ $localModule = OMEKA_PATH . '/modules/' . $moduleNamespace;
+ $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $moduleNamespace;
+
+ // Autoloader would check this file for AnotherService.
+ $relativePath = str_replace('\\', '/', 'AnotherService');
+ $localFile = $localModule . '/src/' . $relativePath . '.php';
+
+ // The autoloader returns false when file doesn't exist, allowing fallback.
+ $this->assertFalse(file_exists($localFile),
+ 'AnotherService should not exist in local, so autoloader returns false and Composer can load from addon');
+
+ // For TestService, verify it loads from local.
+ if (!class_exists($testServiceClass, false)) {
+ $this->assertTrue(class_exists($testServiceClass, true), 'TestService should be autoloadable');
+ $reflection = new \ReflectionClass($testServiceClass);
+ $this->assertStringContainsString('/modules/' . $this->testModuleName, $reflection->getFileName(),
+ 'TestService should be loaded from local modules/');
+ $this->assertEquals('local', $testServiceClass::SOURCE,
+ 'TestService SOURCE should be "local"');
+ }
+ }
+
+ /**
+ * Create an additional service class in a module.
+ */
+ protected function createAdditionalClass($basePath, $className, $source)
+ {
+ $servicePhp = <<testModuleName};
+
+class {$className}
+{
+ const SOURCE = '$source';
+
+ public function getSource(): string
+ {
+ return self::SOURCE;
+ }
+}
+PHP;
+ file_put_contents($basePath . '/src/' . $className . '.php', $servicePhp);
+ }
+
+ /**
+ * Test PSR-4 priority for Common module classes.
+ *
+ * Verifies that Common module classes are loaded from modules/Common/src/
+ * (local) when the module exists in both modules/ and composer-addons/modules/.
+ * This follows standard PSR-4 autoloading - no special handling required.
+ */
+ public function testCommonModulePsr4Priority()
+ {
+ // This test uses the real Common module if it exists in both locations.
+ $localCommon = OMEKA_PATH . '/modules/Common';
+ $addonCommon = OMEKA_PATH . '/composer-addons/modules/Common';
+
+ if (!is_dir($localCommon) || is_link($localCommon) || !is_dir($addonCommon)) {
+ $this->markTestSkipped('Common module not present in both locations.');
+ }
+
+ // Test that TraitModule can be loaded (PSR-4: Common\TraitModule -> src/TraitModule.php).
+ $this->assertTrue(
+ trait_exists('Common\\TraitModule', true),
+ 'Common\\TraitModule should be loadable'
+ );
+
+ // Verify it's loaded from local modules/, not composer-addons/.
+ $reflection = new \ReflectionClass('Common\\TraitModule');
+ $this->assertStringContainsString('/modules/Common/', $reflection->getFileName(),
+ 'Common\\TraitModule should be loaded from modules/Common/');
+ $this->assertStringNotContainsString('/composer-addons/', $reflection->getFileName(),
+ 'Common\\TraitModule should NOT be loaded from addons/');
+ }
+}
diff --git a/application/test/OmekaTest/Module/InfoReaderTest.php b/application/test/OmekaTest/Module/InfoReaderTest.php
new file mode 100644
index 0000000000..775868c9d1
--- /dev/null
+++ b/application/test/OmekaTest/Module/InfoReaderTest.php
@@ -0,0 +1,503 @@
+testPath = sys_get_temp_dir() . '/omeka_test_info_reader_' . uniqid();
+ mkdir($this->testPath, 0755, true);
+ mkdir($this->testPath . '/config', 0755, true);
+
+ $this->infoReader = new InfoReader();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->removeDirectory($this->testPath);
+ parent::tearDown();
+ }
+
+ protected function removeDirectory($path)
+ {
+ if (!is_dir($path)) {
+ return;
+ }
+ $files = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($files as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getRealPath());
+ } else {
+ unlink($file->getRealPath());
+ }
+ }
+ rmdir($path);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: module.ini only
+ // -------------------------------------------------------------------------
+
+ public function testReadModuleIniOnly()
+ {
+ $ini = <<<'INI'
+ [info]
+ name = "Test Module"
+ version = "1.2.3"
+ author = "John Doe"
+ INI;
+ file_put_contents($this->testPath . '/config/module.ini', $ini);
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('Test Module', $info['name']);
+ $this->assertEquals('1.2.3', $info['version']);
+ $this->assertEquals('John Doe', $info['author']);
+ }
+
+ public function testReadThemeIniOnly()
+ {
+ $ini = <<<'INI'
+ [info]
+ name = "Test Theme"
+ version = "2.0.0"
+ INI;
+ file_put_contents($this->testPath . '/config/theme.ini', $ini);
+
+ $info = $this->infoReader->read($this->testPath, 'theme');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('Test Theme', $info['name']);
+ $this->assertEquals('2.0.0', $info['version']);
+ }
+
+ public function testReadModuleIniWithAllFields()
+ {
+ $ini = <<<'INI'
+ [info]
+ name = "Complete Module"
+ version = "3.0.0"
+ author = "Jane Doe"
+ description = "A complete test module"
+ module_link = "https://example.com/module"
+ author_link = "https://example.com/author"
+ support_link = "https://example.com/support"
+ omeka_version_constraint = "^4.0"
+ configurable = true
+ INI;
+ file_put_contents($this->testPath . '/config/module.ini', $ini);
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('Complete Module', $info['name']);
+ $this->assertEquals('3.0.0', $info['version']);
+ $this->assertEquals('Jane Doe', $info['author']);
+ $this->assertEquals('A complete test module', $info['description']);
+ $this->assertEquals('https://example.com/module', $info['module_link']);
+ $this->assertEquals('https://example.com/author', $info['author_link']);
+ $this->assertEquals('https://example.com/support', $info['support_link']);
+ $this->assertEquals('^4.0', $info['omeka_version_constraint']);
+ // configurable from ini is a string, ModuleManagerFactory converts to bool.
+ $this->assertNotEmpty($info['configurable']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: composer.json only
+ // -------------------------------------------------------------------------
+
+ public function testReadComposerJsonOnly()
+ {
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-test-module',
+ 'description' => 'A test module',
+ 'version' => '1.0.0',
+ 'license' => 'GPL-3.0-or-later',
+ 'homepage' => 'https://example.com',
+ 'extra' => [
+ 'label' => 'Test Module Composer',
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('Test Module Composer', $info['name']);
+ $this->assertEquals('1.0.0', $info['version']);
+ $this->assertEquals('A test module', $info['description']);
+ $this->assertEquals('GPL-3.0-or-later', $info['license']);
+ $this->assertEquals('https://example.com', $info['module_link']);
+ }
+
+ public function testReadComposerJsonWithAuthors()
+ {
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-test',
+ 'extra' => [
+ 'label' => 'Test',
+ ],
+ 'authors' => [
+ [
+ 'name' => 'John Smith',
+ 'homepage' => 'https://johnsmith.com',
+ ],
+ [
+ 'name' => 'Jane Smith',
+ ],
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Only first author is used.
+ $this->assertEquals('John Smith', $info['author']);
+ $this->assertEquals('https://johnsmith.com', $info['author_link']);
+ }
+
+ public function testReadComposerJsonWithSupport()
+ {
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-test',
+ 'extra' => [
+ 'label' => 'Test',
+ ],
+ 'support' => [
+ 'issues' => 'https://github.com/vendor/test/issues',
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ $this->assertEquals('https://github.com/vendor/test/issues', $info['support_link']);
+ }
+
+ public function testReadComposerJsonWithKeywords()
+ {
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-test',
+ 'extra' => [
+ 'label' => 'Test',
+ ],
+ 'keywords' => [
+ 'omeka s',
+ 'module',
+ 'search',
+ 'filter',
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Generic keywords should be filtered out.
+ $this->assertEquals('search, filter', $info['tags']);
+ }
+
+ public function testReadComposerJsonForTheme()
+ {
+ $composer = [
+ 'name' => 'vendor/omeka-s-theme-test-theme',
+ 'homepage' => 'https://example.com/theme',
+ 'extra' => [
+ 'label' => 'Test Theme',
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'theme');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('Test Theme', $info['name']);
+ $this->assertEquals('https://example.com/theme', $info['theme_link']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Both sources (composer.json takes precedence)
+ // -------------------------------------------------------------------------
+
+ public function testComposerJsonTakesPrecedenceOverIni()
+ {
+ // Create module.ini with some values.
+ $ini = <<<'INI'
+ [info]
+ name = "Module Ini Name"
+ version = "1.0.0"
+ description = "Description from ini"
+ INI;
+ file_put_contents($this->testPath . '/config/module.ini', $ini);
+
+ // Create composer.json with different values.
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-test',
+ 'version' => '2.0.0',
+ 'description' => 'Description from composer',
+ 'extra' => [
+ 'label' => 'Module Composer Name',
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Composer values should take precedence.
+ $this->assertEquals('Module Composer Name', $info['name']);
+ $this->assertEquals('2.0.0', $info['version']);
+ $this->assertEquals('Description from composer', $info['description']);
+ }
+
+ public function testIniValuesUsedWhenNotInComposer()
+ {
+ // Create module.ini with author.
+ $ini = <<<'INI'
+ [info]
+ name = "Module"
+ version = "1.0.0"
+ author = "Ini Author"
+ support_link = "https://ini-support.com"
+ INI;
+ file_put_contents($this->testPath . '/config/module.ini', $ini);
+
+ // Create composer.json without author/support.
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-test',
+ 'extra' => [
+ 'label' => 'Module',
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Values not in composer should come from ini.
+ $this->assertEquals('Ini Author', $info['author']);
+ $this->assertEquals('https://ini-support.com', $info['support_link']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Defaults and fallbacks
+ // -------------------------------------------------------------------------
+
+ public function testDefaultNameFromDirectoryName()
+ {
+ // No label in extra, so should derive from directory.
+ $composer = [
+ 'name' => 'vendor/test',
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Name should be derived from directory or project name.
+ $this->assertNotEmpty($info['name']);
+ }
+
+ public function testDefaultVersionWhenMissing()
+ {
+ // No version specified.
+ $ini = <<<'INI'
+ [info]
+ name = "Test Module"
+ INI;
+ file_put_contents($this->testPath . '/config/module.ini', $ini);
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Default version should be applied.
+ $this->assertEquals('1.0.0', $info['version']);
+ }
+
+ public function testConfigurableNotSetByDefault()
+ {
+ // configurable is now set by ModuleManagerFactory, not InfoReader.
+ // InfoReader only reads it from module.ini if present.
+ $ini = <<<'INI'
+ [info]
+ name = "Test Module"
+ version = "1.0.0"
+ INI;
+ file_put_contents($this->testPath . '/config/module.ini', $ini);
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ $this->assertArrayNotHasKey('configurable', $info);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Invalid or missing sources
+ // -------------------------------------------------------------------------
+
+ public function testReadReturnsNullWhenNoSources()
+ {
+ // No composer.json and no module.ini.
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ $this->assertNull($info);
+ }
+
+ public function testIsValidReturnsFalseForNull()
+ {
+ $this->assertFalse($this->infoReader->isValid(null));
+ }
+
+ public function testIsValidReturnsFalseForEmptyArray()
+ {
+ $this->assertFalse($this->infoReader->isValid([]));
+ }
+
+ public function testIsValidReturnsFalseWithoutName()
+ {
+ $this->assertFalse($this->infoReader->isValid(['version' => '1.0.0']));
+ }
+
+ public function testIsValidReturnsTrueWithName()
+ {
+ $this->assertTrue($this->infoReader->isValid(['name' => 'Test']));
+ }
+
+ public function testReadInvalidIniFile()
+ {
+ // Create a malformed ini file.
+ file_put_contents($this->testPath . '/config/module.ini', 'invalid [[ content');
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Should return null when ini is invalid and no composer.json.
+ $this->assertNull($info);
+ }
+
+ public function testReadInvalidComposerJson()
+ {
+ // Create a malformed JSON file.
+ file_put_contents($this->testPath . '/composer.json', '{invalid json');
+
+ $info = $this->infoReader->read($this->testPath, 'module');
+
+ // Should return null when JSON is invalid and no ini.
+ $this->assertNull($info);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Project name conversions
+ // -------------------------------------------------------------------------
+
+ public function testProjectNameToLabel()
+ {
+ $reader = new InfoReader();
+
+ $this->assertEquals(
+ 'Easy Admin',
+ $reader->projectNameToLabel('daniel-km/omeka-s-module-easy-admin')
+ );
+ $this->assertEquals(
+ 'Advanced Search',
+ $reader->projectNameToLabel('biblibre/omeka-s-module-advanced-search')
+ );
+ $this->assertEquals(
+ 'Foundation S',
+ $reader->projectNameToLabel('omeka-s-themes/foundation-s')
+ );
+ $this->assertEquals(
+ 'Flavor',
+ $reader->projectNameToLabel('vendor/omeka-s-theme-flavor')
+ );
+ // Suffixes are removed like AddonInstaller::inflect*.
+ $this->assertEquals(
+ 'Datascribe',
+ $reader->projectNameToLabel('chnm/Datascribe-module')
+ );
+ $this->assertEquals(
+ 'Neatline',
+ $reader->projectNameToLabel('vendor/neatline-omeka-s')
+ );
+ }
+
+ public function testProjectNameToDirectory()
+ {
+ $reader = new InfoReader();
+
+ $this->assertEquals(
+ 'EasyAdmin',
+ $reader->projectNameToDirectory('daniel-km/omeka-s-module-easy-admin')
+ );
+ $this->assertEquals(
+ 'AdvancedSearch',
+ $reader->projectNameToDirectory('biblibre/omeka-s-module-advanced-search')
+ );
+ $this->assertEquals(
+ 'FoundationS',
+ $reader->projectNameToDirectory('omeka-s-themes/foundation-s')
+ );
+ // Suffixes are removed like AddonInstaller::inflect*.
+ $this->assertEquals(
+ 'Datascribe',
+ $reader->projectNameToDirectory('chnm/Datascribe-module')
+ );
+ $this->assertEquals(
+ 'Neatline',
+ $reader->projectNameToDirectory('vendor/neatline-omeka-s')
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Installer name from composer.json
+ // -------------------------------------------------------------------------
+
+ public function testGetInstallerNameFromExtra()
+ {
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-test',
+ 'extra' => [
+ 'installer-name' => 'CustomName',
+ ],
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $name = $this->infoReader->getInstallerName($this->testPath);
+
+ $this->assertEquals('CustomName', $name);
+ }
+
+ public function testGetInstallerNameFromProjectName()
+ {
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-easy-admin',
+ ];
+ file_put_contents($this->testPath . '/composer.json', json_encode($composer));
+
+ $name = $this->infoReader->getInstallerName($this->testPath);
+
+ $this->assertEquals('EasyAdmin', $name);
+ }
+
+ public function testGetInstallerNameReturnsNullWithoutComposer()
+ {
+ $name = $this->infoReader->getInstallerName($this->testPath);
+
+ $this->assertNull($name);
+ }
+}
diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php
new file mode 100644
index 0000000000..18cc7ba140
--- /dev/null
+++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php
@@ -0,0 +1,16 @@
+"
+description = "Test theme (addons/themes/) to verify themes/ takes precedence"
+omeka_version_constraint = "^4.2"
diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/view/layout/layout.phtml b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/view/layout/layout.phtml
new file mode 100644
index 0000000000..06c1208eeb
--- /dev/null
+++ b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/view/layout/layout.phtml
@@ -0,0 +1,34 @@
+getHelperPluginManager();
+$testAddonDirectory = $plugins->has('testAddonDirectory') ? $plugins->get('testAddonDirectory') : null;
+
+$this->headLink()->prependStylesheet($this->assetUrl('css/style.css', 'Omeka'));
+?>
+
+
+
+
+
+ = $this->headMeta() ?>
+ = $this->headLink() ?>
+ = $this->headStyle() ?>
+ = $this->headScript() ?>
+ = $this->pageTitle() ?>
+
+
+
+ Theme: TestOverride (composer version)
+ Theme path: = substr(dirname(__DIR__), strlen(OMEKA_PATH)) . '/' ?>
+ Module path: = $testAddonDirectory ? $testAddonDirectory() : 'Module TestOverride is not installed' ?>
+
+
+ = $this->content ?>
+
+
+
diff --git a/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php
new file mode 100644
index 0000000000..2a2842d6f7
--- /dev/null
+++ b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php
@@ -0,0 +1,21 @@
+"
+description = "Local module that should take precedence over the composer version in addons/modules/"
+omeka_version_constraint = "^4.0"
diff --git a/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php
new file mode 100644
index 0000000000..79b141d4a2
--- /dev/null
+++ b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php
@@ -0,0 +1,19 @@
+"
+description = "Local theme (themes/) that should take precedence over addons/themes/"
+omeka_version_constraint = "^4.2"
diff --git a/application/test/OmekaTest/Module/fixtures/themes/test-override/view/layout/layout.phtml b/application/test/OmekaTest/Module/fixtures/themes/test-override/view/layout/layout.phtml
new file mode 100644
index 0000000000..fff739696c
--- /dev/null
+++ b/application/test/OmekaTest/Module/fixtures/themes/test-override/view/layout/layout.phtml
@@ -0,0 +1,34 @@
+getHelperPluginManager();
+$testAddonDirectory = $plugins->has('testAddonDirectory') ? $plugins->get('testAddonDirectory') : null;
+
+$this->headLink()->prependStylesheet($this->assetUrl('css/style.css', 'Omeka'));
+?>
+
+
+
+
+
+ = $this->headMeta() ?>
+ = $this->headLink() ?>
+ = $this->headStyle() ?>
+ = $this->headScript() ?>
+ = $this->pageTitle() ?>
+
+
+
+ Theme: Test Override (local version)
+ Theme path: = substr(dirname(__DIR__), strlen(OMEKA_PATH)) . '/' ?>
+ Module path: = $testAddonDirectory ? $testAddonDirectory() : 'Module TestOverride is not installed' ?>
+
+
+ = $this->content ?>
+
+
+
diff --git a/application/test/OmekaTest/Service/LoggerFactoryTest.php b/application/test/OmekaTest/Service/LoggerFactoryTest.php
index fb22710c1f..dee7018bdb 100644
--- a/application/test/OmekaTest/Service/LoggerFactoryTest.php
+++ b/application/test/OmekaTest/Service/LoggerFactoryTest.php
@@ -12,7 +12,7 @@ class LoggerFactoryTest extends TestCase
protected $validConfig = [
'logger' => [
'log' => true,
- 'path' => '/',
+ 'path' => '/tmp/omeka-test.log',
'priority' => Logger::NOTICE,
],
];
diff --git a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php
new file mode 100644
index 0000000000..44da0a4aa2
--- /dev/null
+++ b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php
@@ -0,0 +1,692 @@
+testModulesPath = sys_get_temp_dir() . '/omeka_test_modules_' . uniqid();
+ $this->testAddonsModulesPath = sys_get_temp_dir() . '/omeka_test_addons_modules_' . uniqid();
+
+ mkdir($this->testModulesPath, 0755, true);
+ mkdir($this->testAddonsModulesPath, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ // Clean up test directories.
+ $this->removeDirectory($this->testModulesPath);
+ $this->removeDirectory($this->testAddonsModulesPath);
+
+ // Clean up any real modules created in OMEKA_PATH.
+ foreach ($this->createdModules as $path) {
+ $this->removeDirectory($path);
+ }
+ $this->createdModules = [];
+
+ parent::tearDown();
+ }
+
+ protected function removeDirectory($path)
+ {
+ if (!is_dir($path)) {
+ return;
+ }
+ $files = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($files as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getRealPath());
+ } else {
+ unlink($file->getRealPath());
+ }
+ }
+ rmdir($path);
+ }
+
+ /**
+ * Create a test module with module.ini only (traditional manual install).
+ */
+ protected function createModuleWithIni($basePath, $moduleName, $version = '1.0.0', $extraIni = [])
+ {
+ $modulePath = $basePath . '/' . $moduleName;
+ mkdir($modulePath, 0755, true);
+ mkdir($modulePath . '/config', 0755, true);
+
+ // Create module.ini.
+ $ini = "[info]\n";
+ $ini .= "name = \"$moduleName\"\n";
+ $ini .= "version = \"$version\"\n";
+ foreach ($extraIni as $key => $value) {
+ $ini .= "$key = \"$value\"\n";
+ }
+ file_put_contents($modulePath . '/config/module.ini', $ini);
+
+ // Create Module.php.
+ $php = " 'test/' . strtolower($moduleName),
+ 'type' => 'omeka-s-module',
+ 'version' => $version,
+ 'extra' => array_merge([
+ 'label' => $moduleName,
+ ], $extra),
+ ];
+ file_put_contents($modulePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT));
+
+ // Create Module.php.
+ $php = " $value) {
+ $ini .= "$key = \"$value\"\n";
+ }
+ file_put_contents($modulePath . '/config/module.ini', $ini);
+
+ // Create composer.json.
+ $composer = [
+ 'name' => 'test/' . strtolower($moduleName),
+ 'type' => 'omeka-s-module',
+ 'version' => $composerVersion,
+ 'extra' => array_merge([
+ 'label' => "$moduleName (composer)",
+ ], $extraComposer),
+ ];
+ file_put_contents($modulePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT));
+
+ // Create Module.php.
+ $php = "assertContains(OMEKA_PATH . '/modules', $modulePaths);
+ $this->assertContains(OMEKA_PATH . '/composer-addons/modules', $modulePaths);
+ }
+
+ /**
+ * Test that modules/ comes before composer-addons/modules/ (for priority).
+ *
+ * Local modules in modules/ should take precedence over
+ * composer-installed modules in composer-addons/modules/.
+ */
+ public function testModulesDirectoryHasPriorityOverAddons()
+ {
+ $config = include OMEKA_PATH . '/application/config/application.config.php';
+ $modulePaths = $config['module_listener_options']['module_paths'];
+
+ $modulesIndex = array_search(OMEKA_PATH . '/modules', $modulePaths);
+ $addonsIndex = array_search(OMEKA_PATH . '/composer-addons/modules', $modulePaths);
+
+ $this->assertNotFalse($modulesIndex, 'modules/ should be in module_paths');
+ $this->assertNotFalse($addonsIndex, 'composer-addons/modules/ should be in module_paths');
+ $this->assertLessThan($addonsIndex, $modulesIndex, 'modules/ should come before composer-addons/modules/');
+ }
+
+ /**
+ * Test that composer-addons/modules directory exists.
+ */
+ public function testAddonsModulesDirectoryExists()
+ {
+ $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/modules');
+ }
+
+ /**
+ * Test that composer-addons/themes directory exists.
+ */
+ public function testAddonsThemesDirectoryExists()
+ {
+ $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/themes');
+ }
+
+ /**
+ * Test that modules/ directory exists (for local/manual modules).
+ */
+ public function testModulesDirectoryExists()
+ {
+ $this->assertDirectoryExists(OMEKA_PATH . '/modules');
+ }
+
+ /**
+ * Test that themes/ directory exists (for local/manual themes).
+ */
+ public function testThemesDirectoryExists()
+ {
+ $this->assertDirectoryExists(OMEKA_PATH . '/themes');
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: InfoReader for modules with different configurations
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test InfoReader reads module with module.ini only.
+ */
+ public function testInfoReaderWithModuleIniOnly()
+ {
+ $modulePath = $this->createModuleWithIni(
+ $this->testModulesPath,
+ 'TestModuleIni',
+ '1.0.0',
+ ['author' => 'Test Author']
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('TestModuleIni', $info['name']);
+ $this->assertEquals('1.0.0', $info['version']);
+ $this->assertEquals('Test Author', $info['author']);
+ }
+
+ /**
+ * Test InfoReader reads module with composer.json only.
+ */
+ public function testInfoReaderWithComposerJsonOnly()
+ {
+ $modulePath = $this->createModuleWithComposer(
+ $this->testModulesPath,
+ 'TestModuleComposer',
+ '2.0.0'
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('TestModuleComposer', $info['name']);
+ $this->assertEquals('2.0.0', $info['version']);
+ }
+
+ /**
+ * Test InfoReader merges module.ini and composer.json (composer takes precedence).
+ */
+ public function testInfoReaderWithBothSources()
+ {
+ $modulePath = $this->createModuleWithBoth(
+ $this->testModulesPath,
+ 'TestModuleBoth',
+ '1.0.0', // ini version
+ '2.0.0', // composer version
+ ['author' => 'Ini Author'] // Only in ini
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ $this->assertNotNull($info);
+ // Composer label takes precedence.
+ $this->assertEquals('TestModuleBoth (composer)', $info['name']);
+ // Composer version takes precedence.
+ $this->assertEquals('2.0.0', $info['version']);
+ // Ini author is preserved (not in composer).
+ $this->assertEquals('Ini Author', $info['author']);
+ }
+
+ /**
+ * Test InfoReader returns null when no sources exist.
+ */
+ public function testInfoReaderWithNoSources()
+ {
+ $modulePath = $this->testModulesPath . '/EmptyModule';
+ mkdir($modulePath, 0755, true);
+
+ // Create Module.php only (no config files).
+ $php = "read($modulePath, 'module');
+
+ $this->assertNull($info);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Priority between modules/ and composer-addons/modules/
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test that a module in modules/ takes precedence over same module in composer-addons/modules/.
+ *
+ * This test creates real modules in OMEKA_PATH to verify factory behavior.
+ */
+ public function testLocalModuleTakesPrecedenceOverAddons()
+ {
+ $moduleName = 'TestPriority_' . uniqid();
+ $localModulePath = OMEKA_PATH . '/modules/' . $moduleName;
+ $addonsModulePath = OMEKA_PATH . '/composer-addons/modules/' . $moduleName;
+
+ $this->createdModules[] = $localModulePath;
+ $this->createdModules[] = $addonsModulePath;
+
+ try {
+ // Create module in composer-addons/modules first.
+ $this->createModuleWithIni($addonsModulePath . '/..', $moduleName, '1.0.0', ['description' => 'Addons version']);
+
+ // Create module in modules/ (should take precedence).
+ $this->createModuleWithIni($localModulePath . '/..', $moduleName, '2.0.0', ['description' => 'Local version']);
+
+ // Read info using InfoReader - should get local version.
+ $infoReader = new InfoReader();
+ $localInfo = $infoReader->read($localModulePath, 'module');
+ $addonsInfo = $infoReader->read($addonsModulePath, 'module');
+
+ // Both should be readable.
+ $this->assertNotNull($localInfo);
+ $this->assertNotNull($addonsInfo);
+ $this->assertEquals('2.0.0', $localInfo['version']);
+ $this->assertEquals('1.0.0', $addonsInfo['version']);
+ $this->assertEquals('Local version', $localInfo['description']);
+ $this->assertEquals('Addons version', $addonsInfo['description']);
+ } finally {
+ // Cleanup is handled in tearDown.
+ }
+ }
+
+ /**
+ * Test module in composer-addons/modules/ only (no local override).
+ */
+ public function testModuleInAddonsOnlyIsRecognized()
+ {
+ $moduleName = 'TestAddonsOnly_' . uniqid();
+ $addonsModulePath = OMEKA_PATH . '/composer-addons/modules/' . $moduleName;
+
+ $this->createdModules[] = $addonsModulePath;
+
+ try {
+ $this->createModuleWithComposer(
+ OMEKA_PATH . '/composer-addons/modules',
+ $moduleName,
+ '1.5.0'
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($addonsModulePath, 'module');
+
+ $this->assertNotNull($info);
+ $this->assertEquals($moduleName, $info['name']);
+ $this->assertEquals('1.5.0', $info['version']);
+ } finally {
+ // Cleanup is handled in tearDown.
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Module validity checks
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test that a directory without Module.php is detected but invalid.
+ */
+ public function testModuleWithoutModulePhp()
+ {
+ $modulePath = $this->testModulesPath . '/NoModulePhp';
+ mkdir($modulePath, 0755, true);
+ mkdir($modulePath . '/config', 0755, true);
+
+ // Create module.ini but no Module.php.
+ $ini = "[info]\nname = \"No Module PHP\"\nversion = \"1.0.0\"\n";
+ file_put_contents($modulePath . '/config/module.ini', $ini);
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ // InfoReader should still read the config.
+ $this->assertNotNull($info);
+ $this->assertTrue($infoReader->isValid($info));
+
+ // But Module.php check is done by the factory, not InfoReader.
+ $this->assertFileDoesNotExist($modulePath . '/Module.php');
+ }
+
+ /**
+ * Test InfoReader with invalid module.ini.
+ */
+ public function testInfoReaderWithInvalidModuleIni()
+ {
+ $modulePath = $this->testModulesPath . '/InvalidIni';
+ mkdir($modulePath, 0755, true);
+ mkdir($modulePath . '/config', 0755, true);
+
+ // Create invalid ini file.
+ file_put_contents($modulePath . '/config/module.ini', 'this is not [[ valid ini');
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ // Should return null (no valid source).
+ $this->assertNull($info);
+ }
+
+ /**
+ * Test InfoReader with invalid composer.json.
+ */
+ public function testInfoReaderWithInvalidComposerJson()
+ {
+ $modulePath = $this->testModulesPath . '/InvalidComposer';
+ mkdir($modulePath, 0755, true);
+
+ // Create invalid JSON file.
+ file_put_contents($modulePath . '/composer.json', '{invalid json');
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ // Should return null (no valid source).
+ $this->assertNull($info);
+ }
+
+ /**
+ * Test InfoReader falls back to ini when composer.json is invalid.
+ */
+ public function testInfoReaderFallsBackToIniWhenComposerInvalid()
+ {
+ $modulePath = $this->testModulesPath . '/FallbackToIni';
+ mkdir($modulePath, 0755, true);
+ mkdir($modulePath . '/config', 0755, true);
+
+ // Create invalid composer.json.
+ file_put_contents($modulePath . '/composer.json', '{invalid json');
+
+ // Create valid module.ini.
+ $ini = "[info]\nname = \"Fallback Module\"\nversion = \"1.0.0\"\n";
+ file_put_contents($modulePath . '/config/module.ini', $ini);
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ // Should use ini as fallback.
+ $this->assertNotNull($info);
+ $this->assertEquals('Fallback Module', $info['name']);
+ $this->assertEquals('1.0.0', $info['version']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Extra composer.json fields
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test configurable flag from module.config.php.
+ */
+ public function testConfigurableFlagFromModuleConfig()
+ {
+ $modulePath = $this->createModuleWithComposer(
+ $this->testModulesPath,
+ 'ConfigurableModule',
+ '1.0.0'
+ );
+
+ // Create module.config.php with configurable = true.
+ $config = " [\n 'configurable' => true,\n ],\n];\n";
+ mkdir($modulePath . '/config', 0755, true);
+ file_put_contents($modulePath . '/config/module.config.php', $config);
+
+ $factory = new ModuleManagerFactory();
+ $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, []]);
+
+ $this->assertTrue($isConfigurable);
+ }
+
+ /**
+ * Test configurable flag fallback to module.ini.
+ */
+ public function testConfigurableFallbackToModuleIni()
+ {
+ $modulePath = $this->createModuleWithIni(
+ $this->testModulesPath,
+ 'ConfigurableIniModule',
+ '1.0.0',
+ ['configurable' => 'true']
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ $factory = new ModuleManagerFactory();
+ $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, $info]);
+
+ $this->assertTrue($isConfigurable);
+ }
+
+ /**
+ * Test configurable defaults to false when not set.
+ */
+ public function testConfigurableDefaultsFalse()
+ {
+ $modulePath = $this->createModuleWithComposer(
+ $this->testModulesPath,
+ 'NonConfigurableModule',
+ '1.0.0'
+ );
+
+ $factory = new ModuleManagerFactory();
+ $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, []]);
+
+ $this->assertFalse($isConfigurable);
+ }
+
+ /**
+ * Test module.config.php takes precedence over module.ini for configurable.
+ */
+ public function testConfigurableModuleConfigTakesPrecedence()
+ {
+ $modulePath = $this->createModuleWithIni(
+ $this->testModulesPath,
+ 'PrecedenceModule',
+ '1.0.0',
+ ['configurable' => 'true']
+ );
+
+ // Create module.config.php with configurable = false.
+ $config = " [\n 'configurable' => false,\n ],\n];\n";
+ file_put_contents($modulePath . '/config/module.config.php', $config);
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($modulePath, 'module');
+
+ $factory = new ModuleManagerFactory();
+ $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, $info]);
+
+ // module.config.php should take precedence over module.ini.
+ $this->assertFalse($isConfigurable);
+ }
+
+ /**
+ * Helper method to invoke protected/private methods.
+ */
+ protected function invokeMethod($object, string $methodName, array $parameters = [])
+ {
+ $reflection = new \ReflectionClass(get_class($object));
+ $method = $reflection->getMethod($methodName);
+ $method->setAccessible(true);
+ return $method->invokeArgs($object, $parameters);
+ }
+
+ /**
+ * Test installer-name in composer.json.
+ */
+ public function testInstallerNameFromComposer()
+ {
+ $modulePath = $this->testModulesPath . '/CustomInstallerName';
+ mkdir($modulePath, 0755, true);
+
+ $composer = [
+ 'name' => 'vendor/omeka-s-module-some-module',
+ 'extra' => [
+ 'installer-name' => 'CustomInstallerName',
+ 'label' => 'Custom Module',
+ ],
+ ];
+ file_put_contents($modulePath . '/composer.json', json_encode($composer));
+
+ $infoReader = new InfoReader();
+ $installerName = $infoReader->getInstallerName($modulePath);
+
+ $this->assertEquals('CustomInstallerName', $installerName);
+ }
+
+ /**
+ * Test installer-name derived from project name.
+ */
+ public function testInstallerNameDerivedFromProjectName()
+ {
+ $modulePath = $this->testModulesPath . '/DerivedName';
+ mkdir($modulePath, 0755, true);
+
+ $composer = [
+ 'name' => 'daniel-km/omeka-s-module-easy-admin',
+ 'extra' => [
+ 'label' => 'Easy Admin',
+ ],
+ ];
+ file_put_contents($modulePath . '/composer.json', json_encode($composer));
+
+ $infoReader = new InfoReader();
+ $installerName = $infoReader->getInstallerName($modulePath);
+
+ $this->assertEquals('EasyAdmin', $installerName);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Local module info takes precedence over installed.json
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test that local module info is used even when module exists in installed.json.
+ *
+ * This is a critical test for the override feature: when a module exists in
+ * both modules/ (local) and composer-addons/modules/ (composer), AND there's an entry
+ * in installed.json, the LOCAL module's info (from module.ini or composer.json)
+ * must be used, NOT the installed.json entry.
+ *
+ * This allows administrators to override a composer-installed module with a
+ * customized local version that has different metadata (version, description, etc.).
+ */
+ public function testLocalModuleInfoTakesPrecedenceOverInstalledJson()
+ {
+ // Use the test fixtures directory.
+ $fixturesPath = __DIR__ . '/../Module/fixtures';
+ $moduleName = 'TestAddonOverride';
+ $localModulePath = $fixturesPath . '/modules/' . $moduleName;
+ $addonsModulePath = $fixturesPath . '/composer-addons/modules/' . $moduleName;
+
+ // Verify fixtures exist.
+ $this->assertDirectoryExists($localModulePath, 'Local module fixture should exist');
+ $this->assertDirectoryExists($addonsModulePath, 'Addons module fixture should exist');
+
+ $infoReader = new InfoReader();
+
+ // The local module.ini has "Test Addon Override (Local version)".
+ $localInfo = $infoReader->read($localModulePath, 'module');
+ $this->assertNotNull($localInfo, 'Local module should be readable');
+ $this->assertStringContainsString('Local', $localInfo['name']);
+
+ // The addons module has "Test Addon Override (Composer version)" in composer.json.
+ $addonsInfo = $infoReader->read($addonsModulePath, 'module');
+ $this->assertNotNull($addonsInfo, 'Addons module should be readable');
+ $this->assertStringContainsString('Composer', $addonsInfo['name']);
+
+ // Test the path-based decision logic used by ModuleManagerFactory.
+ // For a module in modules/, strpos should NOT find '/composer-addons/modules/'.
+ $this->assertFalse(
+ strpos($localModulePath, '/composer-addons/modules/') !== false,
+ 'Local module path should not contain /composer-addons/modules/'
+ );
+
+ // For a module in composer-addons/modules/, strpos SHOULD find '/composer-addons/modules/'.
+ $this->assertTrue(
+ strpos($addonsModulePath, '/composer-addons/modules/') !== false,
+ 'Addons module path should contain /composer-addons/modules/'
+ );
+
+ // Simulate the factory logic: for local modules, DON'T use installed.json.
+ $isComposerAddon = strpos($localModulePath, '/composer-addons/modules/') !== false;
+ $this->assertFalse($isComposerAddon, 'Local module should not be treated as composer addon');
+
+ // The correct info should come from read() of the local path.
+ $info = $infoReader->read($localModulePath, 'module');
+
+ $this->assertStringContainsString(
+ 'Local',
+ $info['name'],
+ 'Module info should come from local module.ini, not composer.json. ' .
+ 'Got: ' . $info['name']
+ );
+ }
+}
diff --git a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php
new file mode 100644
index 0000000000..24f11b4f1a
--- /dev/null
+++ b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php
@@ -0,0 +1,715 @@
+testThemesPath = sys_get_temp_dir() . '/omeka_test_themes_' . uniqid();
+ $this->testAddonsThemesPath = sys_get_temp_dir() . '/omeka_test_addons_themes_' . uniqid();
+
+ mkdir($this->testThemesPath, 0755, true);
+ mkdir($this->testAddonsThemesPath, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ // Clean up test directories.
+ $this->removeDirectory($this->testThemesPath);
+ $this->removeDirectory($this->testAddonsThemesPath);
+
+ // Clean up any real themes created in OMEKA_PATH.
+ foreach ($this->createdThemes as $path) {
+ $this->removeDirectory($path);
+ }
+ $this->createdThemes = [];
+
+ parent::tearDown();
+ }
+
+ protected function removeDirectory($path)
+ {
+ if (!is_dir($path)) {
+ return;
+ }
+ $files = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+ foreach ($files as $file) {
+ if ($file->isDir()) {
+ rmdir($file->getRealPath());
+ } else {
+ unlink($file->getRealPath());
+ }
+ }
+ rmdir($path);
+ }
+
+ /**
+ * Create a test theme with theme.ini only (traditional manual install).
+ */
+ protected function createThemeWithIni($basePath, $themeName, $version = '1.0.0', $extraIni = [])
+ {
+ $themePath = $basePath . '/' . $themeName;
+ mkdir($themePath, 0755, true);
+ mkdir($themePath . '/config', 0755, true);
+
+ // Create theme.ini.
+ $ini = "[info]\n";
+ $ini .= "name = \"$themeName\"\n";
+ $ini .= "version = \"$version\"\n";
+ foreach ($extraIni as $key => $value) {
+ $ini .= "$key = \"$value\"\n";
+ }
+ file_put_contents($themePath . '/config/theme.ini', $ini);
+
+ return $themePath;
+ }
+
+ /**
+ * Create a test theme with composer.json only (composer install without theme.ini).
+ */
+ protected function createThemeWithComposer($basePath, $themeName, $version = '1.0.0', $extra = [])
+ {
+ $themePath = $basePath . '/' . $themeName;
+ mkdir($themePath, 0755, true);
+
+ // Create composer.json.
+ $composer = [
+ 'name' => 'test/' . strtolower($themeName),
+ 'type' => 'omeka-s-theme',
+ 'version' => $version,
+ 'homepage' => 'https://example.com/theme/' . strtolower($themeName),
+ 'extra' => array_merge([
+ 'label' => $themeName,
+ ], $extra),
+ ];
+ file_put_contents($themePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT));
+
+ return $themePath;
+ }
+
+ /**
+ * Create a test theme with both theme.ini and composer.json.
+ */
+ protected function createThemeWithBoth($basePath, $themeName, $iniVersion, $composerVersion, $extraIni = [], $extraComposer = [])
+ {
+ $themePath = $basePath . '/' . $themeName;
+ mkdir($themePath, 0755, true);
+ mkdir($themePath . '/config', 0755, true);
+
+ // Create theme.ini.
+ $ini = "[info]\n";
+ $ini .= "name = \"$themeName (ini)\"\n";
+ $ini .= "version = \"$iniVersion\"\n";
+ foreach ($extraIni as $key => $value) {
+ $ini .= "$key = \"$value\"\n";
+ }
+ file_put_contents($themePath . '/config/theme.ini', $ini);
+
+ // Create composer.json.
+ $composer = [
+ 'name' => 'test/' . strtolower($themeName),
+ 'type' => 'omeka-s-theme',
+ 'version' => $composerVersion,
+ 'homepage' => 'https://example.com/theme/' . strtolower($themeName),
+ 'extra' => array_merge([
+ 'label' => "$themeName (composer)",
+ ], $extraComposer),
+ ];
+ file_put_contents($themePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT));
+
+ return $themePath;
+ }
+
+ // Alias for backward compatibility with existing tests.
+ protected function createTestTheme($basePath, $themeName, $version = '1.0.0')
+ {
+ return $this->createThemeWithIni($basePath, $themeName, $version);
+ }
+
+ /**
+ * Test that composer-addons/themes directory exists.
+ */
+ public function testAddonsThemesDirectoryExists()
+ {
+ $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/themes');
+ }
+
+ /**
+ * Test that themes/ directory exists (for local/manual themes).
+ */
+ public function testThemesDirectoryExists()
+ {
+ $this->assertDirectoryExists(OMEKA_PATH . '/themes');
+ }
+
+ /**
+ * Test that a theme from composer-addons/themes has correct basePath.
+ */
+ public function testThemeFromAddonsDirectoryHasCorrectBasePath()
+ {
+ // Create a theme in the real addons directory for testing.
+ $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/TestThemeAddons_' . uniqid();
+ $themeName = basename($addonsThemePath);
+
+ try {
+ mkdir($addonsThemePath, 0755, true);
+ mkdir($addonsThemePath . '/config', 0755, true);
+
+ $ini = "[info]\n";
+ $ini .= "name = \"Test Theme Addons\"\n";
+ $ini .= "version = \"1.0.0\"\n";
+ file_put_contents($addonsThemePath . '/config/theme.ini', $ini);
+
+ // Create the factory and invoke it.
+ $config = [
+ 'page_templates' => [],
+ 'block_templates' => [],
+ ];
+ $serviceManager = $this->getServiceManager([
+ 'Config' => $config,
+ ]);
+
+ $factory = new ThemeManagerFactory();
+ $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager');
+
+ // Verify theme was registered with correct basePath.
+ $theme = $manager->getTheme($themeName);
+ $this->assertNotNull($theme);
+ $this->assertEquals('composer-addons/themes', $theme->getBasePath());
+ $this->assertStringContainsString('/composer-addons/themes/' . $themeName, $theme->getPath());
+ $this->assertEquals('/composer-addons/themes/' . $themeName . '/theme.jpg', $theme->getThumbnail());
+ } finally {
+ // Clean up.
+ $this->removeDirectory($addonsThemePath);
+ }
+ }
+
+ /**
+ * Test that a theme from themes/ (standard) has correct basePath.
+ */
+ public function testThemeFromStandardDirectoryHasCorrectBasePath()
+ {
+ // Find an existing theme in the standard directory.
+ $themesDir = OMEKA_PATH . '/themes';
+ $foundTheme = null;
+
+ foreach (new \DirectoryIterator($themesDir) as $dir) {
+ if ($dir->isDir() && !$dir->isDot()) {
+ $iniFile = $dir->getPathname() . '/config/theme.ini';
+ if (file_exists($iniFile)) {
+ $foundTheme = $dir->getBasename();
+ break;
+ }
+ }
+ }
+
+ if (!$foundTheme) {
+ $this->markTestSkipped('No standard theme found for testing.');
+ }
+
+ $config = [
+ 'page_templates' => [],
+ 'block_templates' => [],
+ ];
+ $serviceManager = $this->getServiceManager([
+ 'Config' => $config,
+ ]);
+
+ $factory = new ThemeManagerFactory();
+ $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager');
+
+ $theme = $manager->getTheme($foundTheme);
+ $this->assertNotNull($theme);
+ $this->assertEquals('themes', $theme->getBasePath());
+ $this->assertStringContainsString('/themes/' . $foundTheme, $theme->getPath());
+ $this->assertStringNotContainsString('/composer-addons/themes/', $theme->getPath());
+ }
+
+ /**
+ * Test that local theme (themes/) takes precedence over composer-addons/themes/.
+ *
+ * When the same theme exists in both directories, the one in themes/
+ * should be loaded (local override of composer-installed theme).
+ */
+ public function testLocalThemeTakesPrecedenceOverAddons()
+ {
+ // Create a theme in composer-addons/themes.
+ $themeName = 'TestPrecedence_' . uniqid();
+ $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName;
+ $localThemePath = OMEKA_PATH . '/themes/' . $themeName;
+
+ try {
+ // Create addons version first.
+ mkdir($addonsThemePath, 0755, true);
+ mkdir($addonsThemePath . '/config', 0755, true);
+ $ini = "[info]\n";
+ $ini .= "name = \"$themeName (Addons Version)\"\n";
+ $ini .= "version = \"1.0.0\"\n";
+ file_put_contents($addonsThemePath . '/config/theme.ini', $ini);
+
+ // Create local version (should take precedence).
+ mkdir($localThemePath, 0755, true);
+ mkdir($localThemePath . '/config', 0755, true);
+ $ini = "[info]\n";
+ $ini .= "name = \"$themeName (Local Override)\"\n";
+ $ini .= "version = \"2.0.0\"\n";
+ file_put_contents($localThemePath . '/config/theme.ini', $ini);
+
+ $config = [
+ 'page_templates' => [],
+ 'block_templates' => [],
+ ];
+ $serviceManager = $this->getServiceManager([
+ 'Config' => $config,
+ ]);
+
+ $factory = new ThemeManagerFactory();
+ $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager');
+
+ // Verify the local version takes precedence.
+ $theme = $manager->getTheme($themeName);
+ $this->assertNotNull($theme);
+ $this->assertEquals('themes', $theme->getBasePath());
+ $this->assertStringContainsString('Local Override', $theme->getName());
+ $this->assertStringContainsString('/themes/' . $themeName, $theme->getPath());
+ $this->assertStringNotContainsString('/composer-addons/', $theme->getPath());
+ } finally {
+ // Clean up.
+ $this->removeDirectory($addonsThemePath);
+ $this->removeDirectory($localThemePath);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: InfoReader for themes with different configurations
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test InfoReader reads theme with theme.ini only.
+ */
+ public function testInfoReaderWithThemeIniOnly()
+ {
+ $themePath = $this->createThemeWithIni(
+ $this->testThemesPath,
+ 'TestThemeIni',
+ '1.0.0',
+ ['author' => 'Test Author']
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($themePath, 'theme');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('TestThemeIni', $info['name']);
+ $this->assertEquals('1.0.0', $info['version']);
+ $this->assertEquals('Test Author', $info['author']);
+ }
+
+ /**
+ * Test InfoReader reads theme with composer.json only.
+ */
+ public function testInfoReaderWithComposerJsonOnly()
+ {
+ $themePath = $this->createThemeWithComposer(
+ $this->testThemesPath,
+ 'TestThemeComposer',
+ '2.0.0'
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($themePath, 'theme');
+
+ $this->assertNotNull($info);
+ $this->assertEquals('TestThemeComposer', $info['name']);
+ $this->assertEquals('2.0.0', $info['version']);
+ // Theme should have theme_link from homepage.
+ $this->assertEquals('https://example.com/theme/testthemecomposer', $info['theme_link']);
+ }
+
+ /**
+ * Test InfoReader merges theme.ini and composer.json (composer takes precedence).
+ */
+ public function testInfoReaderWithBothSources()
+ {
+ $themePath = $this->createThemeWithBoth(
+ $this->testThemesPath,
+ 'TestThemeBoth',
+ '1.0.0', // ini version
+ '2.0.0', // composer version
+ ['author' => 'Ini Author'] // Only in ini
+ );
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($themePath, 'theme');
+
+ $this->assertNotNull($info);
+ // Composer label takes precedence.
+ $this->assertEquals('TestThemeBoth (composer)', $info['name']);
+ // Composer version takes precedence.
+ $this->assertEquals('2.0.0', $info['version']);
+ // Ini author is preserved (not in composer).
+ $this->assertEquals('Ini Author', $info['author']);
+ }
+
+ /**
+ * Test InfoReader returns null when no sources exist.
+ */
+ public function testInfoReaderWithNoSources()
+ {
+ $themePath = $this->testThemesPath . '/EmptyTheme';
+ mkdir($themePath, 0755, true);
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($themePath, 'theme');
+
+ $this->assertNull($info);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Theme validity checks
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test InfoReader with invalid theme.ini.
+ */
+ public function testInfoReaderWithInvalidThemeIni()
+ {
+ $themePath = $this->testThemesPath . '/InvalidIni';
+ mkdir($themePath, 0755, true);
+ mkdir($themePath . '/config', 0755, true);
+
+ // Create invalid ini file.
+ file_put_contents($themePath . '/config/theme.ini', 'this is not [[ valid ini');
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($themePath, 'theme');
+
+ // Should return null (no valid source).
+ $this->assertNull($info);
+ }
+
+ /**
+ * Test InfoReader with invalid composer.json.
+ */
+ public function testInfoReaderWithInvalidComposerJson()
+ {
+ $themePath = $this->testThemesPath . '/InvalidComposer';
+ mkdir($themePath, 0755, true);
+
+ // Create invalid JSON file.
+ file_put_contents($themePath . '/composer.json', '{invalid json');
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($themePath, 'theme');
+
+ // Should return null (no valid source).
+ $this->assertNull($info);
+ }
+
+ /**
+ * Test InfoReader falls back to ini when composer.json is invalid.
+ */
+ public function testInfoReaderFallsBackToIniWhenComposerInvalid()
+ {
+ $themePath = $this->testThemesPath . '/FallbackToIni';
+ mkdir($themePath, 0755, true);
+ mkdir($themePath . '/config', 0755, true);
+
+ // Create invalid composer.json.
+ file_put_contents($themePath . '/composer.json', '{invalid json');
+
+ // Create valid theme.ini.
+ $ini = "[info]\nname = \"Fallback Theme\"\nversion = \"1.0.0\"\n";
+ file_put_contents($themePath . '/config/theme.ini', $ini);
+
+ $infoReader = new InfoReader();
+ $info = $infoReader->read($themePath, 'theme');
+
+ // Should use ini as fallback.
+ $this->assertNotNull($info);
+ $this->assertEquals('Fallback Theme', $info['name']);
+ $this->assertEquals('1.0.0', $info['version']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Theme with composer.json in composer-addons/themes/
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test theme in composer-addons/themes/ with composer.json only (no theme.ini).
+ */
+ public function testThemeInAddonsWithComposerJsonOnly()
+ {
+ $themeName = 'TestAddonsComposer_' . uniqid();
+ $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName;
+
+ $this->createdThemes[] = $addonsThemePath;
+
+ try {
+ mkdir($addonsThemePath, 0755, true);
+
+ $composer = [
+ 'name' => 'test/' . strtolower($themeName),
+ 'type' => 'omeka-s-theme',
+ 'version' => '1.5.0',
+ 'description' => 'A composer-only theme',
+ 'homepage' => 'https://example.com/theme',
+ 'extra' => [
+ 'label' => 'Composer Theme',
+ ],
+ ];
+ file_put_contents($addonsThemePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT));
+
+ $config = [
+ 'page_templates' => [],
+ 'block_templates' => [],
+ ];
+ $serviceManager = $this->getServiceManager([
+ 'Config' => $config,
+ ]);
+
+ $factory = new ThemeManagerFactory();
+ $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager');
+
+ $theme = $manager->getTheme($themeName);
+ $this->assertNotNull($theme, 'Theme with composer.json only should be recognized');
+ $this->assertEquals('composer-addons/themes', $theme->getBasePath());
+ $this->assertEquals('Composer Theme', $theme->getName());
+ $this->assertEquals('1.5.0', $theme->getIni('version'));
+ $this->assertEquals('A composer-only theme', $theme->getIni('description'));
+ } finally {
+ // Cleanup handled by tearDown.
+ }
+ }
+
+ /**
+ * Test theme in composer-addons/themes/ with both theme.ini and composer.json.
+ */
+ public function testThemeInAddonsWithBothSources()
+ {
+ $themeName = 'TestAddonsBoth_' . uniqid();
+ $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName;
+
+ $this->createdThemes[] = $addonsThemePath;
+
+ try {
+ mkdir($addonsThemePath, 0755, true);
+ mkdir($addonsThemePath . '/config', 0755, true);
+
+ // Create theme.ini.
+ $ini = "[info]\n";
+ $ini .= "name = \"$themeName from ini\"\n";
+ $ini .= "version = \"1.0.0\"\n";
+ $ini .= "author = \"INI Author\"\n";
+ file_put_contents($addonsThemePath . '/config/theme.ini', $ini);
+
+ // Create composer.json with different values.
+ $composer = [
+ 'name' => 'test/' . strtolower($themeName),
+ 'type' => 'omeka-s-theme',
+ 'version' => '2.0.0',
+ 'description' => 'Description from composer',
+ 'extra' => [
+ 'label' => "$themeName from composer",
+ ],
+ ];
+ file_put_contents($addonsThemePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT));
+
+ $config = [
+ 'page_templates' => [],
+ 'block_templates' => [],
+ ];
+ $serviceManager = $this->getServiceManager([
+ 'Config' => $config,
+ ]);
+
+ $factory = new ThemeManagerFactory();
+ $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager');
+
+ $theme = $manager->getTheme($themeName);
+ $this->assertNotNull($theme);
+ // Composer values should take precedence.
+ $this->assertEquals("$themeName from composer", $theme->getName());
+ $this->assertEquals('2.0.0', $theme->getIni('version'));
+ $this->assertEquals('Description from composer', $theme->getIni('description'));
+ // But ini values not in composer should be preserved.
+ $this->assertEquals('INI Author', $theme->getIni('author'));
+ } finally {
+ // Cleanup handled by tearDown.
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: Priority between themes/ and composer-addons/themes/
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test that a theme in themes/ takes precedence over same theme in composer-addons/themes/.
+ *
+ * This test creates real themes in OMEKA_PATH to verify factory behavior.
+ */
+ public function testLocalThemeTakesPrecedenceWithBothUsingComposer()
+ {
+ $themeName = 'TestPriorityComposer_' . uniqid();
+ $localThemePath = OMEKA_PATH . '/themes/' . $themeName;
+ $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName;
+
+ $this->createdThemes[] = $localThemePath;
+ $this->createdThemes[] = $addonsThemePath;
+
+ try {
+ // Create theme in composer-addons/themes with composer.json.
+ mkdir($addonsThemePath, 0755, true);
+ $addonsComposer = [
+ 'name' => 'test/' . strtolower($themeName),
+ 'version' => '1.0.0',
+ 'extra' => [
+ 'label' => 'Addons Version',
+ ],
+ ];
+ file_put_contents($addonsThemePath . '/composer.json', json_encode($addonsComposer));
+
+ // Create theme in themes/ with composer.json (should take precedence).
+ mkdir($localThemePath, 0755, true);
+ $localComposer = [
+ 'name' => 'test/' . strtolower($themeName),
+ 'version' => '2.0.0',
+ 'extra' => [
+ 'label' => 'Local Version',
+ ],
+ ];
+ file_put_contents($localThemePath . '/composer.json', json_encode($localComposer));
+
+ $config = [
+ 'page_templates' => [],
+ 'block_templates' => [],
+ ];
+ $serviceManager = $this->getServiceManager([
+ 'Config' => $config,
+ ]);
+
+ $factory = new ThemeManagerFactory();
+ $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager');
+
+ $theme = $manager->getTheme($themeName);
+ $this->assertNotNull($theme);
+ // Local version should take precedence.
+ $this->assertEquals('themes', $theme->getBasePath());
+ $this->assertEquals('Local Version', $theme->getName());
+ $this->assertEquals('2.0.0', $theme->getIni('version'));
+ } finally {
+ // Cleanup handled by tearDown.
+ }
+ }
+
+ /**
+ * Test theme in composer-addons/themes/ only (no local override).
+ */
+ public function testThemeInAddonsOnlyIsRecognized()
+ {
+ $themeName = 'TestAddonsOnly_' . uniqid();
+ $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName;
+
+ $this->createdThemes[] = $addonsThemePath;
+
+ try {
+ $this->createThemeWithComposer(
+ OMEKA_PATH . '/composer-addons/themes',
+ $themeName,
+ '1.5.0'
+ );
+
+ $config = [
+ 'page_templates' => [],
+ 'block_templates' => [],
+ ];
+ $serviceManager = $this->getServiceManager([
+ 'Config' => $config,
+ ]);
+
+ $factory = new ThemeManagerFactory();
+ $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager');
+
+ $theme = $manager->getTheme($themeName);
+ $this->assertNotNull($theme);
+ $this->assertEquals('composer-addons/themes', $theme->getBasePath());
+ $this->assertEquals($themeName, $theme->getName());
+ $this->assertEquals('1.5.0', $theme->getIni('version'));
+ } finally {
+ // Cleanup handled by tearDown.
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests: installer-name for themes
+ // -------------------------------------------------------------------------
+
+ /**
+ * Test installer-name in composer.json for themes.
+ */
+ public function testInstallerNameFromComposerForTheme()
+ {
+ $themePath = $this->testThemesPath . '/CustomInstallerName';
+ mkdir($themePath, 0755, true);
+
+ $composer = [
+ 'name' => 'vendor/omeka-s-theme-some-theme',
+ 'extra' => [
+ 'installer-name' => 'CustomInstallerName',
+ 'label' => 'Custom Theme',
+ ],
+ ];
+ file_put_contents($themePath . '/composer.json', json_encode($composer));
+
+ $infoReader = new InfoReader();
+ $installerName = $infoReader->getInstallerName($themePath);
+
+ $this->assertEquals('CustomInstallerName', $installerName);
+ }
+
+ /**
+ * Test installer-name derived from project name for themes.
+ */
+ public function testInstallerNameDerivedFromProjectNameForTheme()
+ {
+ $themePath = $this->testThemesPath . '/DerivedName';
+ mkdir($themePath, 0755, true);
+
+ $composer = [
+ 'name' => 'omeka-s-themes/omeka-s-theme-foundation-s',
+ 'extra' => [
+ 'label' => 'Foundation S',
+ ],
+ ];
+ file_put_contents($themePath . '/composer.json', json_encode($composer));
+
+ $infoReader = new InfoReader();
+ $installerName = $infoReader->getInstallerName($themePath);
+
+ $this->assertEquals('FoundationS', $installerName);
+ }
+}
diff --git a/application/test/OmekaTest/Site/Theme/ThemeTest.php b/application/test/OmekaTest/Site/Theme/ThemeTest.php
new file mode 100644
index 0000000000..28c0fbc342
--- /dev/null
+++ b/application/test/OmekaTest/Site/Theme/ThemeTest.php
@@ -0,0 +1,76 @@
+assertEquals('my-theme', $theme->getId());
+ }
+
+ public function testDefaultBasePath()
+ {
+ $theme = new Theme('my-theme');
+ $this->assertEquals('themes', $theme->getBasePath());
+ }
+
+ public function testSetBasePath()
+ {
+ $theme = new Theme('my-theme');
+ $theme->setBasePath('themes/custom');
+ $this->assertEquals('themes/custom', $theme->getBasePath());
+ }
+
+ public function testGetPathWithDefaultBasePath()
+ {
+ $theme = new Theme('my-theme');
+ $path = $theme->getPath();
+ $this->assertStringEndsWith('/themes/my-theme', $path);
+ }
+
+ public function testGetPathWithCustomBasePath()
+ {
+ $theme = new Theme('my-theme');
+ $theme->setBasePath('themes/custom');
+ $path = $theme->getPath();
+ $this->assertStringEndsWith('/themes/custom/my-theme', $path);
+ }
+
+ public function testGetPathWithSubsegments()
+ {
+ $theme = new Theme('my-theme');
+ $theme->setBasePath('themes/custom');
+ $path = $theme->getPath('view', 'layout');
+ $this->assertStringEndsWith('/themes/custom/my-theme/view/layout', $path);
+ }
+
+ public function testGetThumbnailWithDefaultBasePath()
+ {
+ $theme = new Theme('my-theme');
+ $thumbnail = $theme->getThumbnail();
+ $this->assertEquals('/themes/my-theme/theme.jpg', $thumbnail);
+ }
+
+ public function testGetThumbnailWithCustomBasePath()
+ {
+ $theme = new Theme('my-theme');
+ $theme->setBasePath('themes/custom');
+ $thumbnail = $theme->getThumbnail();
+ $this->assertEquals('/themes/custom/my-theme/theme.jpg', $thumbnail);
+ }
+
+ public function testGetThumbnailWithKeyUsesDefaultPath()
+ {
+ // When passing a key for another theme, the default path is used
+ // because we don't know the basePath of the other theme.
+ $theme = new Theme('my-theme');
+ $theme->setBasePath('themes/custom');
+ $thumbnail = $theme->getThumbnail('other-theme');
+ $this->assertEquals('/themes/other-theme/theme.jpg', $thumbnail);
+ }
+}
diff --git a/bootstrap.php b/bootstrap.php
index 66f55b7711..ecb357a2c0 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -4,3 +4,40 @@
date_default_timezone_set('UTC');
require 'vendor/autoload.php';
+
+/*
+ * Autoloader to prioritize local modules/ over Composer's composer-addons/modules/.
+ *
+ * When a module exists in both locations, this ensures classes are loaded
+ * from modules/ (local) instead of composer-addons/modules/ (Composer).
+ *
+ * Registered AFTER Composer with prepend=true to run BEFORE Composer.
+ */
+spl_autoload_register(function ($class) {
+ // Extract module namespace (first segment)
+ $pos = strpos($class, '\\');
+ if ($pos === false) {
+ return false;
+ }
+ $moduleNamespace = substr($class, 0, $pos);
+
+ // Check for conflict: module exists in both locations
+ $localModule = OMEKA_PATH . '/modules/' . $moduleNamespace;
+ $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $moduleNamespace;
+
+ // Only intervene if local exists as real dir (not symlink) AND addon exists
+ if (!is_dir($localModule) || is_link($localModule) || !is_dir($addonModule)) {
+ return false; // No conflict, let Composer handle it
+ }
+
+ // PSR-4: ModuleName\Foo\Bar -> modules/ModuleName/src/Foo/Bar.php
+ $relativePath = str_replace('\\', '/', substr($class, $pos + 1));
+ $file = $localModule . '/src/' . $relativePath . '.php';
+
+ if (file_exists($file)) {
+ require_once $file;
+ return true;
+ }
+
+ return false;
+}, true, true); // prepend=true to run BEFORE Composer
diff --git a/composer-addons/README.md b/composer-addons/README.md
new file mode 100644
index 0000000000..049c5790a7
--- /dev/null
+++ b/composer-addons/README.md
@@ -0,0 +1,134 @@
+Omeka S Addons
+==============
+
+Add-ons are modules and themes managed by composer when they have the type
+`omeka-s-module` or `omeka-s-theme`.
+
+The use of add-ons allows to use a single command `composer require xxx/yyy`
+to manage an Omeka instance, to avoid duplication of dependencies, and to
+improve speed of Omeka init. With composer, add-ons are installed automatically
+under `composer-addons/modules/` and `composer-addons/themes/` and their own
+dependencies are shared with the Omeka ones in `vendor/`.
+
+Omeka S still supports classic locations `modules/` and `themes/`. When a module
+or a theme with the same name is located in `modules/` or `themes/`, it is
+prioritary and the composer add-on is skipped.
+
+If a module has no composer.json file or is not available on
+https://packagist.org, it still can be managed via composer via the key
+`repositories` (see https://getcomposer.org/doc/04-schema.md#repositories).
+
+
+Version compatibility
+---------------------
+
+Add-ons can require `omeka/omeka-s` to declare compatibility with a specific
+Omeka version:
+
+```json
+{
+ "require": {
+ "omeka/omeka-s": "^4.0"
+ }
+}
+```
+
+Omeka S uses Composer's `branch-alias` mechanism to ensure version constraints
+work on both tagged releases and development branches. The version constraint
+is automatically checked at install time.
+
+For manual add-ons (without composer), use `omeka_version_constraint` in
+`config/module.ini` or `config/theme.ini`.
+
+
+Extra keys
+----------
+
+The file composer.json supports optional specific keys under key `extra`:
+
+- `installer-name`: directory to use when different from project name.
+- `label`: display label when different from project name.
+
+If an extra key is not available, a check is done for an equivalent in file
+`config/module.ini` or `config/theme.ini`, if present, else a default value is
+set.
+
+
+Configurable modules
+--------------------
+
+To declare a module as configurable, add the key in `config/module.config.php`:
+
+```php
+return [
+ 'module_config' => [
+ 'configurable' => true,
+ ],
+ // ... other config
+];
+```
+
+This takes precedence over `config/module.ini` (fallback for legacy modules).
+
+
+External assets
+---------------
+
+Modules and themes that need external js/css/fonts/img libraries can use another
+composer plugins like [sempia/external-assets](https://gitlab.com/sempia/composer-plugin-external-assets),
+a lightweight solution, or [civicrm/composer-downloads-plugin](https://github.com/civicrm/composer-downloads-plugin),
+a full featured tool (variables, ignore patterns, executable flag), or any other
+one.
+
+Example using `sempia/external-assets`:
+
+```json
+{
+ "require": {
+ "sempia/external-assets": "^1.0"
+ },
+ "extra": {
+ "external-assets": {
+ "asset/vendor/mirador/": "https://github.com/ProjectMirador/mirador/releases/download/v3.3.0/mirador.zip",
+ "asset/vendor/lib/jquery.min.js": "https://cdn.example.com/jquery-3.7.0.min.js"
+ }
+ }
+}
+```
+
+
+Manual installation
+-------------------
+
+For add-ons installed manually via `git clone` in directory `modules/` or
+`themes/`, dependencies are not downloaded automatically. Use the following
+script from the Omeka root:
+
+```sh
+# 1. Clone the add-on
+git clone https://gitlab.com/user/MyModule modules/MyModule
+
+# 2. Install composer dependencies (other modules, libraries)
+php application/data/scripts/install-addon-deps.php MyModule
+```
+
+### Install dependencies
+
+```sh
+# Module
+php application/data/scripts/install-addon-deps.php ModuleName
+
+# Theme
+php application/data/scripts/install-addon-deps.php --theme theme-name
+
+# Preview without installing
+php application/data/scripts/install-addon-deps.php --dry-run ModuleName
+```
+
+
+Funding
+-------
+
+This feature was funded for the [digital library Manioc](https://manioc.org) of
+the [Université des Antilles](https://www.univ-antilles.fr) (subvention
+Agence bibliographique de l'enseignement supérieur [Abes](https://abes.fr)).
diff --git a/composer-addons/modules/.gitkeep b/composer-addons/modules/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/composer-addons/themes/.gitkeep b/composer-addons/themes/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/composer.lock b/composer.lock
index 9feb88b713..9cee8d87ef 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4227,14 +4227,15 @@
},
{
"name": "omeka/composer-addon-installer",
- "version": "2.0",
+ "version": "3.0",
"dist": {
"type": "path",
"url": "application/data/composer-addon-installer",
- "reference": "7397409eb65cbdf0000ad73a1c0cc38bae53fff1"
+ "reference": "82f262c0558d4379c2418e8cd4d5b39212ffb364"
},
"require": {
- "composer-plugin-api": "^2.0"
+ "composer-plugin-api": "^2.0",
+ "php": ">=8.1"
},
"type": "composer-plugin",
"extra": {
@@ -4248,6 +4249,7 @@
"license": [
"GPL-3.0"
],
+ "description": "Composer plugin to install Omeka S modules and themes into composer-addons/modules/ and composer-addons/themes/.",
"transport-options": {
"relative": true
}