ENHANCEMENT Allow vendor modules with url rewriting

API Introduce ModuleResource feature
This commit is contained in:
Damian Mooyman 2017-09-21 17:54:06 +12:00
parent 91943c03e5
commit fa57deeba4
No known key found for this signature in database
GPG Key ID: 78B823A10DE27D1A
40 changed files with 886 additions and 275 deletions

2
.gitignore vendored
View File

@ -8,6 +8,6 @@ node_modules/
coverage/
/**/*.js.map
/**/*.css.map
vendor/
/vendor/
composer.lock
silverstripe-cache/

View File

@ -1,11 +1,6 @@
---
Name: coreconfig
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Core\Manifest\ResourceURLGenerator:
class: SilverStripe\Control\SimpleResourceURLGenerator
properties:
NonceStyle: mtime
SilverStripe\Control\HTTP:
cache_control:
max-age: 0

8
_config/resources.yml Normal file
View File

@ -0,0 +1,8 @@
---
Name: coreresources
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Core\Manifest\ResourceURLGenerator:
class: SilverStripe\Control\SimpleResourceURLGenerator
properties:
NonceStyle: mtime

View File

@ -29,6 +29,8 @@ As you've added new files to your SilverStripe installation, make sure you clear
`?flush=1` to your website URL (e.g http://yoursite.com/?flush=1).
</div>
### Configuring themes
After installing the files through either method, update the current theme in SilverStripe. This can be done by
either altering the `SSViewer.themes` setting in a [config.yml](../configuration) or by changing the current theme in
the Site Configuration panel (http://yoursite.com/admin/settings)
@ -42,6 +44,31 @@ SilverStripe\View\SSViewer:
- '$default'
```
There are a variety of ways in which you can specify a theme. The below describe the three
main styles of syntax:
1. You can use the following to point to a theme or path within your root project:
- `themename` -> A simple name with no slash represents a theme in the `/themes` directory
- `/some/path/to/theme` - Any `/` prefixed string will be treated as a direct filesystem path to a theme root.
- `$themeset` - Any `$` prefixed name will refer to a set of themes. By default only `$default` set is configured,
which represents all module roots with a `templates` directory.
2. Using the `:` syntax you can also specify themes relative to the given module:
- `myvendor/mymodule:sometheme` - This will specify a standard theme within the given module.
This will lookup the theme in the `themes` subfolder within this module. E.g.
`/vendor/myvendor/mymodule/themes/sometheme`.
Note: This syntax also works without the vendor prefix (`mymodule:sometheme`)
- `myvendor/mymodule:/some/path` - Rather than looking in the themes subdir, look in the
exact path within the root of the given module.
3. You can also specify a module root folder directly.
- `myvendor/mymodule` - Points to the base folder of the given module.
- `mymodule:` - Also points to the base folder of the given module, but without a vendor.
The `:` is necessary to distinguish this from a non-module theme.
### Manually
Unpack the contents of the zip file you download into the `themes` directory in your SilverStripe installation. The

View File

@ -17,39 +17,87 @@ this:
**mycustommodule/composer.json**
```js
{
"name": "your-vendor-name/module-name",
"description": "One-liner describing your module",
"type": "silverstripe-module",
"homepage": "http://github.com/your-vendor-name/module-name",
"keywords": ["silverstripe", "some-tag", "some-other-tag"],
"license": "BSD-3-Clause",
"authors": [
{"name": "Your Name","email": "your@email.com"}
],
"support": {
"issues": "http://github.com/your-vendor-name/module-name/issues"
},
"require": {
"silverstripe/cms": "~3.1",
"silverstripe/framework": "~3.1"
},
"extra": {
"installer-name": "module-name",
"screenshots": [
"relative/path/screenshot1.png",
"http://myhost.com/screenshot2.png"
]
}
}
```json
{
"name": "your-vendor-name/module-name",
"description": "One-liner describing your module",
"type": "silverstripe-module",
"homepage": "http://github.com/your-vendor-name/module-name",
"keywords": ["silverstripe", "some-tag", "some-other-tag"],
"license": "BSD-3-Clause",
"authors": [
{"name": "Your Name","email": "your@email.com"}
],
"support": {
"issues": "http://github.com/your-vendor-name/module-name/issues"
},
"require": {
"silverstripe/cms": "^4",
"silverstripe/framework": "^4"
},
"extra": {
"installer-name": "module-name",
"screenshots": [
"relative/path/screenshot1.png",
"http://myhost.com/screenshot2.png"
]
}
}
```
Once your module is published online with a service like Github.com or Bitbucket.com, submit the repository to
[Packagist](https://packagist.org/) to have the module accessible to developers. It'll automatically get picked
up by [addons.silverstripe.org](http://addons.silverstripe.org/) website.
## Vendor modules
By default `silverstripe-module` type libraries are installed to the root web folder, however a new type
`silverstripe-vendormodule` allows you to publish your module to the vendor directory.
The below is an example of a vendor module composer.json:
```json
{
"name": "tractorcow/test-vendor-module",
"description": "Test module for silverstripe/vendor-plugin",
"type": "silverstripe-vendormodule",
"require": {
"silverstripe/vendor-plugin": "^1.0",
"silverstripe/cms": "^4.0"
},
"license": "BSD-3-Clause",
"autoload": {
"psr-4": {
"TractorCow\\TestVendorModule\\": "src/"
}
},
"extra": {
"expose": [
"client"
]
},
"minimum-stability": "dev"
}
```
Note that these modules have the following distinct characteristics:
- Library type is `silverstripe-vendormodule`
- Any folder which should be exposed to the public webroot must be declared in the `extra.expose` config.
These paths will be automatically rewritten to public urls which don't directly serve files from the `vendor`
folder. For instance, `vendor/tractorcow/test-vendor-module/client` will be rewritten to
`resources/tractorcow/test-vendor-module/client`.
- Any module which uses the folder expose feature must require `silverstripe/vendor-plugin` in order to
support automatic rewriting and linking. For more information on this plugin you can see the
[silverstripe/vendor-plugin github page](https://github.com/silverstripe/vendor-plugin).
Linking to resources in vendor modules uses exactly the same syntax as non-vendor modules. For example,
this is how you would require a script in this module:
```php
Requirements::javascript('tractorcow/test-vendor-module:client/js/script.js');
```
## Releasing versions
Over time you may have to release new versions of your module to continue to work with newer versions of SilverStripe.
@ -76,4 +124,4 @@ Here's some common values for your `require` section
* `3.0.*`: Version `3.0`, including `3.0.1`, `3.0.2` etc, excluding `3.1`
* `~3.0`: Version `3.0` or higher, including `3.0.1` and `3.1` etc, excluding `4.0`
* `~3.0,<3.2`: Version `3.0` or higher, up until `3.2`, which is excluded
* `~3.0,>3.0.4`: Version `3.0` or higher, starting with `3.0.4`
* `~3.0,>3.0.4`: Version `3.0` or higher, starting with `3.0.4`

View File

@ -64,6 +64,8 @@ guide developers in preparing existing 3.x code for compatibility with 4.0
[intervention/image](https://github.com/intervention/image) library to power manipualations.
* Dependencies can managed via [recipe-plugin](https://github.com/silverstripe/recipe-plugin). See [recipe-core](https://github.com/silverstripe/recipe-core) and [recipe-cms](https://github.com/silverstripe/recipe-cms) as examples.
* Authentication has been upgraded to a modular approach using re-usable interfaces and easier to hook in to LoginHandlers.
* Support for modules installed in vendor folder. See [/developer_guides/extending/how_tos/publish_a_module](the
module publishing guide) for more information.
## <a name="upgrading"></a>Upgrading

View File

@ -3,6 +3,8 @@
namespace SilverStripe\Control;
use InvalidArgumentException;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Manifest\ModuleResource;
use SilverStripe\Core\Manifest\ResourceURLGenerator;
/**
@ -11,6 +13,18 @@ use SilverStripe\Core\Manifest\ResourceURLGenerator;
*/
class SimpleResourceURLGenerator implements ResourceURLGenerator
{
/**
* Rewrites applied after generating url.
* Note: requires either silverstripe/vendor-plugin-helper or silverstripe/vendor-plugin
* to ensure the file is available.
*
* @config
* @var array
*/
private static $url_rewrites = [
'#^vendor/#i' => 'resources/',
];
/*
* @var string
*/
@ -45,18 +59,34 @@ class SimpleResourceURLGenerator implements ResourceURLGenerator
/**
* Return the URL for a resource, prefixing with Director::baseURL() and suffixing with a nonce
*
* @param string $relativePath File or directory path relative to BASE_PATH
* @param string|ModuleResource $relativePath File or directory path relative to BASE_PATH
* @return string Doman-relative URL
* @throws InvalidArgumentException If the resource doesn't exist
*/
public function urlForResource($relativePath)
{
$absolutePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $relativePath);
if (!file_exists($absolutePath)) {
if ($relativePath instanceof ModuleResource) {
// Load from module resource
$resource = $relativePath;
$relativePath = $resource->getRelativePath();
$exists = $resource->exists();
$absolutePath = $resource->getPath();
} else {
// Use normal string
$absolutePath = preg_replace('/\?.*/', '', Director::baseFolder() . '/' . $relativePath);
$exists = file_exists($absolutePath);
}
if (!$exists) {
throw new InvalidArgumentException("File {$relativePath} does not exist");
}
// Apply url rewrites
$rules = Config::inst()->get(static::class, 'url_rewrites') ?: [];
foreach ($rules as $from => $to) {
$relativePath = preg_replace($from, $to, $relativePath);
}
// Apply nonce
$nonce = '';
// Don't add nonce to directories
if ($this->nonceStyle && is_file($absolutePath)) {

View File

@ -444,7 +444,7 @@ class ClassManifest
'name_regex' => '/^[^_].*\\.php$/',
'ignore_files' => array('index.php', 'main.php', 'cli-script.php'),
'ignore_tests' => !$includeTests,
'file_callback' => function ($basename, $pathname) use ($includeTests) {
'file_callback' => function ($basename, $pathname, $depth) use ($includeTests, $finder) {
$this->handleFile($basename, $pathname, $includeTests);
},
));

View File

@ -16,63 +16,230 @@ use SilverStripe\Assets\FileFinder;
class ManifestFileFinder extends FileFinder
{
const CONFIG_FILE = '_config.php';
const CONFIG_DIR = '_config';
const CONFIG_FILE = '_config.php';
const CONFIG_DIR = '_config';
const EXCLUDE_FILE = '_manifest_exclude';
const LANG_DIR = 'lang';
const TESTS_DIR = 'tests';
const LANG_DIR = 'lang';
const TESTS_DIR = 'tests';
const VENDOR_DIR = 'vendor';
const RESOURCES_DIR = 'resources';
protected static $default_options = array(
'include_themes' => false,
'ignore_tests' => true,
'min_depth' => 1,
'ignore_dirs' => array('node_modules')
'ignore_tests' => true,
'min_depth' => 1,
'ignore_dirs' => ['node_modules']
);
public function acceptDir($basename, $pathname, $depth)
{
// Skip over the assets directory in the site root.
if ($depth == 1 && $basename == ASSETS_DIR) {
// Skip if ignored
if ($this->isInsideIgnored($basename, $pathname, $depth)) {
return false;
}
// Skip over any lang directories in the top level of the module.
if ($depth == 2 && $basename == self::LANG_DIR) {
return false;
// Keep searching inside vendor
$inVendor = $this->isInsideVendor($basename, $pathname, $depth);
if ($inVendor) {
// Keep searching if we could have a subdir module
if ($depth < 3) {
return true;
}
// Stop searching if we are in a non-module library
$libraryPath = $this->upLevels($pathname, $depth - 3);
$libraryBase = basename($libraryPath);
if (!$this->isDirectoryModule($libraryBase, $libraryPath, 3)) {
return false;
}
}
// Skip over the vendor directories
if (($depth == 1 || $depth == 2) && $basename == 'vendor') {
return false;
// Include themes
if ($this->getOption('include_themes') && $this->isInsideThemes($basename, $pathname, $depth)) {
return true;
}
// If we're not in testing mode, then skip over any tests directories.
if ($this->getOption('ignore_tests') && $basename == self::TESTS_DIR) {
return false;
}
// Ignore any directories which contain a _manifest_exclude file.
if (file_exists($pathname . '/' . self::EXCLUDE_FILE)) {
return false;
}
// Only include top level module directories which have a configuration
// _config.php file. However, if we're in themes mode then include
// the themes dir without a config file.
$lackingConfig = (
$depth == 1
&& !($this->getOption('include_themes') && $basename == THEMES_DIR)
&& !file_exists($pathname . '/' . self::CONFIG_FILE)
&& !file_exists($pathname . '/' . self::CONFIG_DIR)
&& $basename !== self::CONFIG_DIR // include a root config dir
&& !file_exists("$pathname/../" . self::CONFIG_DIR) // include all paths if a root config dir exists
&& !file_exists("$pathname/../" . self::CONFIG_FILE)// include all paths if a root config file exists
);
if ($lackingConfig) {
// Skip if not in module
if (!$this->isInsideModule($basename, $pathname, $depth)) {
return false;
}
return parent::acceptDir($basename, $pathname, $depth);
}
/**
* Check if the given dir is, or is inside the vendor folder
*
* @param string $basename
* @param string $pathname
* @param int $depth
* @return bool
*/
public function isInsideVendor($basename, $pathname, $depth)
{
$base = basename($this->upLevels($pathname, $depth - 1));
return $base === self::VENDOR_DIR;
}
/**
* Check if the given dir is, or is inside the themes folder
*
* @param string $basename
* @param string $pathname
* @param int $depth
* @return bool
*/
public function isInsideThemes($basename, $pathname, $depth)
{
$base = basename($this->upLevels($pathname, $depth - 1));
return $base === THEMES_DIR;
}
/**
* Check if this folder or any parent is ignored
*
* @param string $basename
* @param string $pathname
* @param int $depth
* @return bool
*/
public function isInsideIgnored($basename, $pathname, $depth)
{
return $this->anyParents($basename, $pathname, $depth, function ($basename, $pathname, $depth) {
return $this->isDirectoryIgnored($basename, $pathname, $depth);
});
}
/**
* Check if this folder is inside any module
*
* @param string $basename
* @param string $pathname
* @param int $depth
* @return bool
*/
public function isInsideModule($basename, $pathname, $depth)
{
return $this->anyParents($basename, $pathname, $depth, function ($basename, $pathname, $depth) {
return $this->isDirectoryModule($basename, $pathname, $depth);
});
}
/**
* Check if any parents match the given callback
*
* @param string $basename
* @param string $pathname
* @param int $depth
* @param callable $callback
* @return bool
*/
protected function anyParents($basename, $pathname, $depth, $callback)
{
// Check all ignored dir up the path
while ($depth >= 0) {
$ignored = $callback($basename, $pathname, $depth);
if ($ignored) {
return true;
}
$pathname = dirname($pathname);
$basename = basename($pathname);
$depth--;
}
return false;
}
/**
* Check if the given dir is a module root (not a subdir)
*
* @param string $basename
* @param string $pathname
* @param string $depth
* @return bool
*/
public function isDirectoryModule($basename, $pathname, $depth)
{
// Depth can either be 0, 1, or 3 (if and only if inside vendor)
$inVendor = $this->isInsideVendor($basename, $pathname, $depth);
if ($depth > 0 && $depth !== ($inVendor ? 3 : 1)) {
return false;
}
// True if config file exists
if (file_exists($pathname . '/' . self::CONFIG_FILE)) {
return true;
}
// True if config dir exists
if (file_exists($pathname . '/' . self::CONFIG_DIR)) {
return true;
}
return false;
}
/**
* Get a parent path the given levels above
*
* @param string $pathname
* @param int $depth Number of parents to rise
* @return string
*/
protected function upLevels($pathname, $depth)
{
if ($depth < 0) {
return null;
}
while ($depth--) {
$pathname = dirname($pathname);
}
return $pathname;
}
/**
* Get all ignored directories
*
* @return array
*/
protected function getIgnoredDirs()
{
$ignored = [self::LANG_DIR, 'node_modules'];
if ($this->getOption('ignore_tests')) {
$ignored[] = self::TESTS_DIR;
}
return $ignored;
}
/**
* Check if the given directory is ignored
* @param string $basename
* @param string $pathname
* @param string $depth
* @return bool
*/
public function isDirectoryIgnored($basename, $pathname, $depth)
{
// Don't ignore root
if ($depth === 0) {
return false;
}
// Check if manifest-ignored is present
if (file_exists($pathname . '/' . self::EXCLUDE_FILE)) {
return true;
}
// Check if directory name is ignored
$ignored = $this->getIgnoredDirs();
if (in_array($basename, $ignored)) {
return true;
}
// Ignore these dirs in the root only
if ($depth === 1 && in_array($basename, [ASSETS_DIR, self::RESOURCES_DIR])) {
return true;
}
return false;
}
}

View File

@ -4,19 +4,21 @@ namespace SilverStripe\Core\Manifest;
use Exception;
use Serializable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
class Module implements Serializable
{
const TRIM_CHARS = '/\\';
/**
* Directory
* Full directory path to this module with no trailing slash
*
* @var string
*/
protected $path = null;
/**
* Base folder of application
* Base folder of application with no trailing slash
*
* @var string
*/
@ -29,10 +31,23 @@ class Module implements Serializable
*/
protected $composerData = null;
/**
* Loaded resources for this module
*
* @var ModuleResource[]
*/
protected $resources = [];
/**
* Construct a module
*
* @param string $path Absolute filesystem path to this module
* @param string $base base url for the application this module is installed in
*/
public function __construct($path, $base)
{
$this->path = rtrim($path, '/\\');
$this->basePath = rtrim($base, '/\\');
$this->path = rtrim($path, self::TRIM_CHARS);
$this->basePath = rtrim($base, self::TRIM_CHARS);
$this->loadComposer();
}
@ -62,6 +77,19 @@ class Module implements Serializable
return null;
}
/**
* Get list of folders that need to be made available
*
* @return array
*/
public function getExposedFolders()
{
if (isset($this->composerData['extra']['expose'])) {
return $this->composerData['extra']['expose'];
}
return [];
}
/**
* Gets "short" name of this module. This is the base directory this module
* is installed in.
@ -94,7 +122,7 @@ class Module implements Serializable
/**
* Get base path for this module
*
* @return string
* @return string Path with no trailing slash E.g. /var/www/module
*/
public function getPath()
{
@ -105,11 +133,11 @@ class Module implements Serializable
* Get path relative to base dir.
* If module path is base this will be empty string
*
* @return string
* @return string Path with trimmed slashes. E.g. vendor/silverstripe/module.
*/
public function getRelativePath()
{
return ltrim(substr($this->path, strlen($this->basePath)), '/\\');
return trim(substr($this->path, strlen($this->basePath)), self::TRIM_CHARS);
}
public function serialize()
@ -120,6 +148,7 @@ class Module implements Serializable
public function unserialize($serialized)
{
list($this->path, $this->basePath, $this->composerData) = json_decode($serialized, true);
$this->resources = [];
}
/**
@ -152,63 +181,69 @@ class Module implements Serializable
}
/**
* Gets path to physical file resource relative to base directory.
* Directories included
* Get resource for this module
*
* This method makes no distinction between public / local resources,
* which may change in the near future.
*
* @internal Experimental API and may change
* @param string $path File or directory path relative to module directory
* @return string Path relative to base directory
* @param string $path
* @return ModuleResource
*/
public function getResource($path)
{
$path = trim($path, '/\\');
if (isset($this->resources[$path])) {
return $this->resources[$path];
}
return $this->resources[$path] = new ModuleResource($this, $path);
}
/**
* @deprecated 4.0...5.0 Use getResource($path)->getRelativePath() instead
* @param string $path
* @return string
*/
public function getRelativeResourcePath($path)
{
$base = trim($this->getRelativePath(), '/\\');
$path = trim($path, '/\\');
return trim("{$base}/{$path}", '/\\');
Deprecation::notice('5.0', 'Use getResource($path)->getRelativePath() instead');
return $this
->getResource($path)
->getRelativePath();
}
/**
* Gets path to physical file resource relative to base directory.
* Directories included
*
* This method makes no distinction between public / local resources,
* which may change in the near future.
*
* @internal Experimental API and may change
* @param string $path File or directory path relative to module directory
* @return string Path relative to base directory
* @deprecated 4.0...5.0 Use ->getResource($path)->getPath() instead
* @param string $path
* @return string
*/
public function getResourcePath($path)
{
return $this->basePath . '/' . $this->getRelativeResourcePath($path);
Deprecation::notice('5.0', 'Use getResource($path)->getPath() instead');
return $this
->getResource($path)
->getPath();
}
/**
* Gets the URL for a given resource.
* Relies on the ModuleURLGenerator Injector service to do the heavy lifting
*
* @internal Experimental API and may change
* @param string $path File or directory path relative to module directory
* @return string URL, either domain-relative (starting with /) or absolute
* @deprecated 4.0...5.0 Use ->getResource($path)->getURL() instead
* @param string $path
* @return string
*/
public function getResourceURL($path)
{
return Injector::inst()
->get(ResourceURLGenerator::class)
->urlForResource($this->getRelativeResourcePath($path));
Deprecation::notice('5.0', 'Use getResource($path)->getURL() instead');
return $this
->getResource($path)
->getURL();
}
/**
* Check if this module has a given resource
*
* @internal Experimental API and may change
* @deprecated 4.0...5.0 Use ->getResource($path)->exists() instead
* @param string $path
* @return bool
* @return string
*/
public function hasResource($path)
{
return file_exists($this->getResourcePath($path));
Deprecation::notice('5.0', 'Use getResource($path)->exists() instead');
return $this
->getResource($path)
->exists();
}
}

View File

@ -5,7 +5,6 @@ namespace SilverStripe\Core\Manifest;
use LogicException;
use Psr\SimpleCache\CacheInterface;
use SilverStripe\Core\Cache\CacheFactory;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injector;
@ -171,52 +170,25 @@ class ModuleManifest
$finder = new ManifestFileFinder();
$finder->setOptions(array(
'min_depth' => 0,
'name_regex' => '/(^|[\/\\\\])_config.php$/',
'ignore_tests' => !$includeTests,
'file_callback' => array($this, 'addSourceConfigFile'),
// Cannot be max_depth: 1 due to "/framework/admin/_config.php"
'max_depth' => 2
'dir_callback' => function ($basename, $pathname, $depth) use ($finder) {
if ($finder->isDirectoryModule($basename, $pathname, $depth)) {
$this->addModule($pathname);
}
}
));
$finder->find($this->base);
$finder = new ManifestFileFinder();
$finder->setOptions(array(
'name_regex' => '/\.ya?ml$/',
'ignore_tests' => !$includeTests,
'file_callback' => array($this, 'addYAMLConfigFile'),
'max_depth' => 2
));
$finder->find($this->base);
// Include root itself if module
if ($finder->isDirectoryModule(basename($this->base), $this->base, 0)) {
$this->addModule($this->base);
}
if ($this->cache) {
$this->cache->set($this->cacheKey, $this->modules);
}
}
/**
* Record finding of _config.php file
*
* @param string $basename
* @param string $pathname
*/
public function addSourceConfigFile($basename, $pathname)
{
$this->addModule(dirname($pathname));
}
/**
* Handle lookup of _config/*.yml file
*
* @param string $basename
* @param string $pathname
*/
public function addYAMLConfigFile($basename, $pathname)
{
if (preg_match('{/([^/]+)/_config/}', $pathname, $match)) {
$this->addModule(dirname(dirname($pathname)));
}
}
/**
* Get module by name
*

View File

@ -0,0 +1,121 @@
<?php
namespace SilverStripe\Core\Manifest;
use InvalidArgumentException;
use SilverStripe\Core\Injector\Injector;
/**
* This object represents a single resource file attached to a module, and can be used
* as a reference to this to be later turned into either a URL or file path.
*/
class ModuleResource
{
/**
* @var Module
*/
protected $module = null;
/**
* Path to this resource relative to the module (no leading slash)
*
* @var string
*/
protected $relativePath = null;
/**
* ModuleResource constructor.
*
* @param Module $module
* @param string $relativePath
*/
public function __construct(Module $module, $relativePath)
{
$this->module = $module;
$this->relativePath = ltrim($relativePath, Module::TRIM_CHARS);
if (empty($this->relativePath)) {
throw new InvalidArgumentException("Resource cannot have empty path");
}
}
/**
* Return the full filesystem path to this resource.
*
* Note: In the case that this resource is mapped to the `resources` folder, this will
* return the original rather than the copy / symlink.
*
* @return string Path with no trailing slash E.g. /var/www/module
*/
public function getPath()
{
return $this->module->getPath() . '/' . $this->relativePath;
}
/**
* Get the path of this resource relative to the base path.
*
* Note: In the case that this resource is mapped to the `resources` folder, this will
* return the original rather than the copy / symlink.
*
* @return string Relative path (no leading /)
*/
public function getRelativePath()
{
$path = $this->module->getRelativePath() . '/' . $this->relativePath;
return ltrim($path, Module::TRIM_CHARS);
}
/**
* Public URL to this resource.
* Note: May be either absolute url, or root-relative url
*
* In the case that this resource is mapped to the `resources` folder this
* will be the mapped url rather than the original path.
*
* @return string
*/
public function getURL()
{
/** @var ResourceURLGenerator $generator */
$generator = Injector::inst()->get(ResourceURLGenerator::class);
return $generator->urlForResource($this->getRelativePath());
}
/**
* Synonym for getURL() for APIs that expect a Link method
*
* @return mixed
*/
public function Link()
{
return $this->getURL();
}
/**
* Determine if this resource exists
*
* @return bool
*/
public function exists()
{
return file_exists($this->getPath());
}
/**
* Get relative path
*
* @return string
*/
public function __toString()
{
return $this->getRelativePath();
}
/**
* @return Module
*/
public function getModule()
{
return $this->module;
}
}

View File

@ -15,7 +15,7 @@ interface ResourceURLGenerator
* As well as returning the URL, this method may also perform any changes needed to ensure that this
* URL will resolve, for example, by copying files to another location
*
* @param string $resource File or directory path relative to BASE_PATH
* @param string|ModuleResource $resource File or directory path relative to BASE_PATH, or ModuleResource instance
* @return string URL, either domain-relative (starting with /) or absolute
* @throws InvalidArgumentException If the resource doesn't exist or can't be sent to the browser
*/

View File

@ -2,6 +2,8 @@
namespace SilverStripe\View;
use InvalidArgumentException;
use RuntimeException;
use SilverStripe\Core\Manifest\ModuleLoader;
/**
@ -95,56 +97,59 @@ class ThemeResourceLoader
public function getPath($identifier)
{
$slashPos = strpos($identifier, '/');
$parts = explode(':', $identifier, 2);
// If identifier starts with "/", it's a path from root
if ($slashPos === 0) {
return substr($identifier, 1);
} // Otherwise if there is a "/", identifier is a vendor'ed module
elseif ($slashPos !== false) {
// Extract from <vendor>/<module>:<theme> format.
// <vendor> is optional, and if <theme> is omitted it defaults to the module root dir.
// If <theme> is included, this is the name of the directory under moduleroot/themes/
// which contains the theme.
// <module> is always the name of the install directory, not necessarily the composer name.
$parts = explode(':', $identifier, 2);
if (count($parts) > 1) {
$theme = $parts[1];
// "module/vendor:/sub/path"
if ($theme[0] === '/') {
$subpath = $theme;
// "module/vendor:subtheme"
} else {
$subpath = '/themes/' . $theme;
}
// "module/vendor"
} else {
$subpath = '';
throw new InvalidArgumentException("Invalid theme identifier {$identifier}");
}
return substr($identifier, 1);
}
$package = $parts[0];
// Find matching module for this package
$module = ModuleLoader::inst()->getManifest()->getModule($package);
if ($module) {
$modulePath = $module->getRelativePath();
} else {
// fall back to dirname
list(, $modulePath) = explode('/', $parts[0], 2);
// If the module is in the themes/<module>/ prefer that
if (is_dir(THEMES_PATH . '/' .$modulePath)) {
$modulePath = THEMES_DIR . '/' . $$modulePath;
}
}
return ltrim($modulePath . $subpath, '/');
} // Otherwise it's a (deprecated) old-style "theme" identifier
else {
// If there is no slash / colon it's a legacy theme
if ($slashPos === false && count($parts) === 1) {
return THEMES_DIR.'/'.$identifier;
}
// Extract from <vendor>/<module>:<theme> format.
// <vendor> is optional, and if <theme> is omitted it defaults to the module root dir.
// If <theme> is included, this is the name of the directory under moduleroot/themes/
// which contains the theme.
// <module> is always the name of the install directory, not necessarily the composer name.
// Find module from first part
$moduleName = $parts[0];
$module = ModuleLoader::inst()->getManifest()->getModule($moduleName);
if ($module) {
$modulePath = $module->getRelativePath();
} else {
// If no module could be found, assume based on basename
// with a warning
if (strstr('/', $moduleName)) {
list(, $modulePath) = explode('/', $parts[0], 2);
} else {
$modulePath = $moduleName;
}
trigger_error("No module named {$moduleName} found. Assuming path {$modulePath}", E_USER_WARNING);
}
// Parse relative path for this theme within this module
$theme = count($parts) > 1 ? $parts[1] : '';
if (empty($theme)) {
// "module/vendor:"
// "module/vendor"
$subpath = '';
} elseif (strpos($theme, '/') === 0) {
// "module/vendor:/sub/path"
$subpath = rtrim($theme, '/');
} else {
// "module/vendor:subtheme"
$subpath = '/themes/' . $theme;
}
// Join module with subpath
return $modulePath . $subpath;
}
/**

View File

@ -120,7 +120,7 @@ class ConfigContext implements Context
$project = ModuleManifest::config()->get('project') ?: 'mysite';
$mysite = ModuleLoader::getModule($project);
assertNotNull($mysite, 'Project exists');
$destPath = $mysite->getResourcePath("_config/{$filename}");
$destPath = $mysite->getResource("_config/{$filename}")->getPath();
assertFileNotExists($destPath, "Config file {$filename} hasn't aleady been loaded");
// Load

View File

@ -0,0 +1,67 @@
<?php
namespace SilverStripe\Control\Tests;
use SilverStripe\Control\Director;
use SilverStripe\Control\SimpleResourceURLGenerator;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\Module;
use SilverStripe\Core\Manifest\ResourceURLGenerator;
use SilverStripe\Dev\SapphireTest;
class SimpleResourceURLGeneratorTest extends SapphireTest
{
protected function setUp()
{
parent::setUp();
Director::config()->set(
'alternate_base_folder',
__DIR__ .'/SimpleResourceURLGeneratorTest/_fakewebroot'
);
Director::config()->set(
'alternate_base_url',
'http://www.mysite.com/'
);
}
public function testAddMTime()
{
/** @var SimpleResourceURLGenerator $generator */
$generator = Injector::inst()->get(ResourceURLGenerator::class);
$mtime = filemtime(__DIR__ .'/SimpleResourceURLGeneratorTest/_fakewebroot/basemodule/client/file.js');
$this->assertEquals(
'/basemodule/client/file.js?m='.$mtime,
$generator->urlForResource('basemodule/client/file.js')
);
}
public function testVendorResource()
{
/** @var SimpleResourceURLGenerator $generator */
$generator = Injector::inst()->get(ResourceURLGenerator::class);
$mtime = filemtime(
__DIR__ .'/SimpleResourceURLGeneratorTest/_fakewebroot/vendor/silverstripe/mymodule/client/style.css'
);
$this->assertEquals(
'/resources/silverstripe/mymodule/client/style.css?m='.$mtime,
$generator->urlForResource('vendor/silverstripe/mymodule/client/style.css')
);
}
public function testModuleResource()
{
/** @var SimpleResourceURLGenerator $generator */
$generator = Injector::inst()->get(ResourceURLGenerator::class);
$module = new Module(
__DIR__ .'/SimpleResourceURLGeneratorTest/_fakewebroot/vendor/silverstripe/mymodule/',
__DIR__ .'/SimpleResourceURLGeneratorTest/_fakewebroot/'
);
$mtime = filemtime(
__DIR__ .'/SimpleResourceURLGeneratorTest/_fakewebroot/vendor/silverstripe/mymodule/client/style.css'
);
$this->assertEquals(
'/resources/silverstripe/mymodule/client/style.css?m='.$mtime,
$generator->urlForResource($module->getResource('client/style.css'))
);
}
}

View File

@ -0,0 +1 @@
/* basemodule/file.js */

View File

@ -0,0 +1,2 @@
/* basemodule/style.css */
body {}

View File

@ -0,0 +1 @@
/* mymodule/file.js */

View File

@ -0,0 +1,2 @@
/* mymodule/style.css */
body {}

View File

@ -32,39 +32,51 @@ class ClassManifestTest extends SapphireTest
parent::setUp();
$this->base = dirname(__FILE__) . '/fixtures/classmanifest';
$this->manifest = new ClassManifest($this->base);
$this->manifest = new ClassManifest($this->base);
$this->manifest->init(false);
$this->manifestTests = new ClassManifest($this->base);
$this->manifestTests->init(true);
}
public function testGetItemPath()
/**
* @return array
*/
public function providerTestGetItemPath()
{
$expect = array(
'CLASSA' => 'module/classes/ClassA.php',
'ClassA' => 'module/classes/ClassA.php',
'classa' => 'module/classes/ClassA.php',
'INTERFACEA' => 'module/interfaces/InterfaceA.php',
'InterfaceA' => 'module/interfaces/InterfaceA.php',
'interfacea' => 'module/interfaces/InterfaceA.php',
'TestTraitA' => 'module/traits/TestTraitA.php',
'TestNamespace\Testing\TestTraitB' => 'module/traits/TestTraitB.php'
);
return [
['CLASSA', 'module/classes/ClassA.php'],
['ClassA', 'module/classes/ClassA.php'],
['classa', 'module/classes/ClassA.php'],
['INTERFACEA', 'module/interfaces/InterfaceA.php'],
['InterfaceA', 'module/interfaces/InterfaceA.php'],
['interfacea', 'module/interfaces/InterfaceA.php'],
['TestTraitA', 'module/traits/TestTraitA.php'],
['TestNamespace\\Testing\\TestTraitB', 'module/traits/TestTraitB.php'],
['VendorClassA', 'vendor/silverstripe/modulec/code/VendorClassA.php'],
['VendorTraitA', 'vendor/silverstripe/modulec/code/VendorTraitA.php'],
];
}
foreach ($expect as $name => $path) {
$this->assertEquals("{$this->base}/$path", $this->manifest->getItemPath($name));
}
/**
* @dataProvider providerTestGetItemPath
* @param string $name
* @param string $path
*/
public function testGetItemPath($name, $path)
{
$this->assertEquals("{$this->base}/$path", $this->manifest->getItemPath($name));
}
public function testGetClasses()
{
$expect = array(
'classa' => "{$this->base}/module/classes/ClassA.php",
'classb' => "{$this->base}/module/classes/ClassB.php",
'classc' => "{$this->base}/module/classes/ClassC.php",
'classd' => "{$this->base}/module/classes/ClassD.php",
'classe' => "{$this->base}/module/classes/ClassE.php",
);
$expect = [
'classa' => "{$this->base}/module/classes/ClassA.php",
'classb' => "{$this->base}/module/classes/ClassB.php",
'classc' => "{$this->base}/module/classes/ClassC.php",
'classd' => "{$this->base}/module/classes/ClassD.php",
'classe' => "{$this->base}/module/classes/ClassE.php",
'vendorclassa' => "{$this->base}/vendor/silverstripe/modulec/code/VendorClassA.php",
];
$this->assertEquals($expect, $this->manifest->getClasses());
}
@ -77,6 +89,7 @@ class ClassManifestTest extends SapphireTest
'classc' => 'ClassC',
'classd' => 'ClassD',
'classe' => 'ClassE',
'vendorclassa' => 'VendorClassA',
],
$this->manifest->getClassNames()
);
@ -85,10 +98,11 @@ class ClassManifestTest extends SapphireTest
public function testGetTraitNames()
{
$this->assertEquals(
array(
[
'testtraita' => 'TestTraitA',
'testnamespace\testing\testtraitb' => 'TestNamespace\Testing\TestTraitB',
),
'testnamespace\\testing\\testtraitb' => 'TestNamespace\\Testing\\TestTraitB',
'vendortraita' => 'VendorTraitA',
],
$this->manifest->getTraitNames()
);
}

View File

@ -10,16 +10,23 @@ use SilverStripe\Dev\SapphireTest;
*/
class ManifestFileFinderTest extends SapphireTest
{
protected $base;
protected $defaultBase;
public function __construct()
{
$this->defaultBase = dirname(__FILE__) . '/fixtures/manifestfilefinder';
$this->defaultBase = __DIR__ . '/fixtures/manifestfilefinder';
parent::__construct();
}
public function assertFinderFinds($finder, $base, $expect, $message = null)
/**
* Test that the finder can find the given files
*
* @param ManifestFileFinder $finder
* @param string $base
* @param array $expect
* @param string $message
*/
public function assertFinderFinds(ManifestFileFinder $finder, $base, $expect, $message = null)
{
if (!$base) {
$base = $this->defaultBase;
@ -45,9 +52,10 @@ class ManifestFileFinderTest extends SapphireTest
$this->assertFinderFinds(
$finder,
null,
array(
'module/module.txt'
)
[
'module/module.txt',
'vendor/myvendor/thismodule/module.txt',
]
);
}
@ -60,11 +68,14 @@ class ManifestFileFinderTest extends SapphireTest
$this->assertFinderFinds(
$finder,
null,
array(
'module/module.txt',
'module/tests/tests.txt',
'module/code/tests/tests2.txt'
)
[
'module/module.txt',
'module/tests/tests.txt',
'module/code/tests/tests2.txt',
'vendor/myvendor/thismodule/module.txt',
'vendor/myvendor/thismodule/tests/tests.txt',
'vendor/myvendor/thismodule/code/tests/tests2.txt',
]
);
}
@ -77,10 +88,11 @@ class ManifestFileFinderTest extends SapphireTest
$this->assertFinderFinds(
$finder,
null,
array(
'module/module.txt',
'themes/themes.txt'
)
[
'module/module.txt',
'themes/themes.txt',
'vendor/myvendor/thismodule/module.txt',
]
);
}
@ -90,10 +102,8 @@ class ManifestFileFinderTest extends SapphireTest
$this->assertFinderFinds(
$finder,
dirname(__FILE__) . '/fixtures/manifestfilefinder_rootconfigfile',
array(
'code/code.txt',
)
__DIR__ . '/fixtures/manifestfilefinder_rootconfigfile',
[ 'code/code.txt' ]
);
}
@ -103,11 +113,11 @@ class ManifestFileFinderTest extends SapphireTest
$this->assertFinderFinds(
$finder,
dirname(__FILE__) . '/fixtures/manifestfilefinder_rootconfigfolder',
array(
__DIR__ . '/fixtures/manifestfilefinder_rootconfigfolder',
[
'_config/config.yml',
'code/code.txt',
)
]
);
}
}

View File

@ -31,9 +31,10 @@ class ModuleManifestTest extends SapphireTest
$modules = $this->manifest->getModules();
$this->assertEquals(
[
'silverstripe/root-module',
'module',
'silverstripe/awesome-module',
'silverstripe/modulec',
'silverstripe/root-module',
],
array_keys($modules)
);
@ -71,32 +72,37 @@ class ModuleManifestTest extends SapphireTest
$this->assertEquals('moduleb', $module->getRelativePath());
}
/*
* Note: Tests experimental API
* @internal
*/
public function testGetResourcePath()
{
$module = $this->manifest->getModule('moduleb');
$this->assertTrue($module->hasResource('composer.json'));
$this->assertFalse($module->hasResource('package.json'));
// Root module
$moduleb = $this->manifest->getModule('moduleb');
$this->assertTrue($moduleb->getResource('composer.json')->exists());
$this->assertFalse($moduleb->getResource('package.json')->exists());
$this->assertEquals(
'moduleb/composer.json',
$module->getRelativeResourcePath('composer.json')
$moduleb->getResource('composer.json')->getRelativePath()
);
}
public function testGetResourcePathsInVendor()
{
// Vendor module
$modulec = $this->manifest->getModule('silverstripe/modulec');
$this->assertTrue($modulec->getResource('composer.json')->exists());
$this->assertFalse($modulec->getResource('package.json')->exists());
$this->assertEquals(
'vendor/silverstripe/modulec/composer.json',
$modulec->getResource('composer.json')->getRelativePath()
);
}
/*
* Note: Tests experimental API
* @internal
*/
public function testGetResourcePathOnRoot()
{
$module = $this->manifest->getModule('silverstripe/root-module');
$this->assertTrue($module->hasResource('composer.json'));
$this->assertTrue($module->getResource('composer.json')->exists());
$this->assertEquals(
'composer.json',
$module->getRelativeResourcePath('composer.json')
$module->getResource('composer.json')->getRelativePath()
);
}
}

View File

@ -2,6 +2,7 @@
namespace SilverStripe\Core\Tests\Manifest;
use SilverStripe\Control\Director;
use SilverStripe\Core\Manifest\ModuleLoader;
use SilverStripe\View\ThemeResourceLoader;
use SilverStripe\View\ThemeManifest;
@ -37,6 +38,7 @@ class ThemeResourceLoaderTest extends SapphireTest
// Fake project root
$this->base = dirname(__FILE__) . '/fixtures/templatemanifest';
Director::config()->set('alternate_base_folder', $this->base);
ModuleManifest::config()->set('module_priority', ['$project', '$other_modules']);
ModuleManifest::config()->set('project', 'myproject');
@ -87,8 +89,8 @@ class ThemeResourceLoaderTest extends SapphireTest
$this->loader->findTemplate(
'NestedThemePage',
[
'silverstripe/module:subtheme',
'$default'
'silverstripe/module:subtheme',
'$default'
]
)
);
@ -99,7 +101,7 @@ class ThemeResourceLoaderTest extends SapphireTest
$this->loader->findTemplate(
'NestedThemePage',
[
'silverstripe/module:subtheme',
'silverstripe/module:subtheme',
]
)
);
@ -166,8 +168,8 @@ class ThemeResourceLoaderTest extends SapphireTest
"$this->base/themes/theme/templates/Page.ss",
$this->loader->findTemplate(
[
"$this->base/themes/theme/templates/Page.ss",
"Page"
"$this->base/themes/theme/templates/Page.ss",
"Page"
],
['theme']
)
@ -178,8 +180,8 @@ class ThemeResourceLoaderTest extends SapphireTest
"$this->base/themes/theme/templates/Page.ss",
$this->loader->findTemplate(
[
"$this->base/themes/theme/templates/NotAPage.ss",
"$this->base/themes/theme/templates/Page.ss",
"$this->base/themes/theme/templates/NotAPage.ss",
"$this->base/themes/theme/templates/Page.ss",
],
['theme']
)
@ -310,4 +312,69 @@ class ThemeResourceLoaderTest extends SapphireTest
unlink($template);
}
}
public function providerTestGetPath()
{
return [
// Legacy theme
[
'theme',
'themes/theme',
],
// Module themes
[
'silverstripe/vendormodule:vendortheme',
'vendor/silverstripe/vendormodule/themes/vendortheme',
],
[
'module:subtheme',
'module/themes/subtheme',
],
// Module absolute paths
[
'silverstripe/vendormodule:/themes/vendortheme',
'vendor/silverstripe/vendormodule/themes/vendortheme',
],
[
'module:/themes/subtheme',
'module/themes/subtheme',
],
// Module root directory
[
'silverstripe/vendormodule:/',
'vendor/silverstripe/vendormodule',
],
[
'silverstripe/vendormodule:',
'vendor/silverstripe/vendormodule',
],
[
'silverstripe/vendormodule',
'vendor/silverstripe/vendormodule',
],
[
'module:',
'module',
],
// Absolute paths
[
'/vendor/silverstripe/vendormodule/themes/vendortheme',
'vendor/silverstripe/vendormodule/themes/vendortheme',
],
[
'/module/themes/subtheme',
'module/themes/subtheme'
]
];
}
/**
* @dataProvider providerTestGetPath
* @param string $name Theme identifier
* @param string $path Path to theme
*/
public function testGetPath($name, $path)
{
$this->assertEquals($path, $this->loader->getPath($name));
}
}

View File

@ -0,0 +1,4 @@
---
Name: blankconfig
---
{}

View File

@ -0,0 +1,6 @@
<?php
class VendorClassA
{
}

View File

@ -0,0 +1,6 @@
<?php
trait VendorTraitA
{
}

View File

@ -0,0 +1,5 @@
{
"name": "silverstripe/modulec",
"description": "dummy test module",
"require": {}
}

View File

@ -0,0 +1,4 @@
{
"name": "silverstripe/module",
"type": "silverstripe-module"
}

View File

@ -0,0 +1,4 @@
{
"name": "silverstripe/vendormodule",
"type": "silverstripe-vendormodule"
}

View File

@ -712,11 +712,11 @@ PHP;
$collector = new Collector();
$modules = ModuleLoader::inst()->getManifest()->getModules();
$this->assertEquals(
array(
[
'i18nnonstandardmodule',
'i18nothermodule',
'i18ntestmodule',
'i18nothermodule'
),
],
array_keys($modules)
);