Issues (2882)

src/View/ThemeResourceLoader.php (4 issues)

Severity
1
<?php
2
3
namespace SilverStripe\View;
4
5
use InvalidArgumentException;
6
use Psr\SimpleCache\CacheInterface;
7
use SilverStripe\Core\Flushable;
8
use SilverStripe\Core\Injector\Injector;
9
use SilverStripe\Core\Manifest\ModuleLoader;
10
use SilverStripe\Core\Path;
11
12
/**
13
 * Handles finding templates from a stack of template manifest objects.
14
 */
15
class ThemeResourceLoader implements Flushable
16
{
17
18
    /**
19
     * @var ThemeResourceLoader
20
     */
21
    private static $instance;
22
23
    /**
24
     * The base path of the application
25
     *
26
     * @var string
27
     */
28
    protected $base;
29
30
    /**
31
     * List of template "sets" that contain a test manifest, and have an alias.
32
     * E.g. '$default'
33
     *
34
     * @var ThemeList[]
35
     */
36
    protected $sets = [];
37
38
    /**
39
     * @var CacheInterface
40
     */
41
    protected $cache;
42
43
    /**
44
     * @return ThemeResourceLoader
45
     */
46
    public static function inst()
47
    {
48
        return self::$instance ? self::$instance : self::$instance = new self();
49
    }
50
51
    /**
52
     * Set instance
53
     *
54
     * @param ThemeResourceLoader $instance
55
     */
56
    public static function set_instance(ThemeResourceLoader $instance)
57
    {
58
        self::$instance = $instance;
59
    }
60
61
    public function __construct($base = null)
62
    {
63
        $this->base = $base ? $base : BASE_PATH;
64
    }
65
66
    /**
67
     * Add a new theme manifest for a given identifier. E.g. '$default'
68
     *
69
     * @param string $set
70
     * @param ThemeList $manifest
71
     */
72
    public function addSet($set, ThemeList $manifest)
73
    {
74
        $this->sets[$set] = $manifest;
75
    }
76
77
    /**
78
     * Get a named theme set
79
     *
80
     * @param string $set
81
     * @return ThemeList
82
     */
83
    public function getSet($set)
84
    {
85
        if (isset($this->sets[$set])) {
86
            return $this->sets[$set];
87
        }
88
        return null;
89
    }
90
91
    /**
92
     * Given a theme identifier, determine the path from the root directory
93
     *
94
     * The mapping from $identifier to path follows these rules:
95
     * - A simple theme name ('mytheme') which maps to the standard themes dir (/themes/mytheme)
96
     * - A theme path with a leading slash ('/mymodule/themes/mytheme') which maps directly to that path.
97
     * - or a vendored theme path. (vendor/mymodule:mytheme) which maps to the nested 'theme' within
98
     *   that module. ('/mymodule/themes/mytheme').
99
     * - A vendored module with no nested theme (vendor/mymodule) which maps to the root directory
100
     *   of that module. ('/mymodule').
101
     *
102
     * @param string $identifier Theme identifier.
103
     * @return string Path from root, not including leading or trailing forward slash. E.g. themes/mytheme
104
     */
105
    public function getPath($identifier)
106
    {
107
        $slashPos = strpos($identifier, '/');
108
        $parts = explode(':', $identifier, 2);
109
110
        // If identifier starts with "/", it's a path from root
111
        if ($slashPos === 0) {
112
            if (count($parts) > 1) {
113
                throw new InvalidArgumentException("Invalid theme identifier {$identifier}");
114
            }
115
            return Path::normalise($identifier, true);
116
        }
117
118
        // If there is no slash / colon it's a legacy theme
119
        if ($slashPos === false && count($parts) === 1) {
120
            return Path::join(THEMES_DIR, $identifier);
121
        }
122
123
        // Extract from <vendor>/<module>:<theme> format.
124
        // <vendor> is optional, and if <theme> is omitted it defaults to the module root dir.
125
        // If <theme> is included, this is the name of the directory under moduleroot/themes/
126
        // which contains the theme.
127
        // <module> is always the name of the install directory, not necessarily the composer name.
128
129
        // Find module from first part
130
        $moduleName = $parts[0];
131
        $module = ModuleLoader::inst()->getManifest()->getModule($moduleName);
132
        if ($module) {
0 ignored issues
show
$module is of type SilverStripe\Core\Manifest\Module, thus it always evaluated to true.
Loading history...
133
            $modulePath = $module->getRelativePath();
134
        } else {
135
            // If no module could be found, assume based on basename
136
            // with a warning
137
            if (strstr('/', $moduleName)) {
138
                list(, $modulePath) = explode('/', $parts[0], 2);
139
            } else {
140
                $modulePath = $moduleName;
141
            }
142
            trigger_error("No module named {$moduleName} found. Assuming path {$modulePath}", E_USER_WARNING);
143
        }
144
145
        // Parse relative path for this theme within this module
146
        $theme = count($parts) > 1 ? $parts[1] : '';
147
        if (empty($theme)) {
148
            // "module/vendor:"
149
            // "module/vendor"
150
            $subpath = '';
151
        } elseif (strpos($theme, '/') === 0) {
152
            // "module/vendor:/sub/path"
153
            $subpath = rtrim($theme, '/');
154
        } else {
155
            // "module/vendor:subtheme"
156
            $subpath = '/themes/' . $theme;
157
        }
158
159
        // Join module with subpath
160
        return Path::normalise($modulePath . $subpath, true);
161
    }
162
163
    /**
164
     * Attempts to find possible candidate templates from a set of template
165
     * names from modules, current theme directory and finally the application
166
     * folder.
167
     *
168
     * The template names can be passed in as plain strings, or be in the
169
     * format "type/name", where type is the type of template to search for
170
     * (e.g. Includes, Layout).
171
     *
172
     * The results of this method will be cached for future use.
173
     *
174
     * @param string|array $template Template name, or template spec in array format with the keys
175
     * 'type' (type string) and 'templates' (template hierarchy in order of precedence).
176
     * If 'templates' is ommitted then any other item in the array will be treated as the template
177
     * list, or list of templates each in the array spec given.
178
     * Templates with an .ss extension will be treated as file paths, and will bypass
179
     * theme-coupled resolution.
180
     * @param array $themes List of themes to use to resolve themes. Defaults to {@see SSViewer::get_themes()}
181
     * @return string Absolute path to resolved template file, or null if not resolved.
182
     * File location will be in the format themes/<theme>/templates/<directories>/<type>/<basename>.ss
183
     * Note that type (e.g. 'Layout') is not the root level directory under 'templates'.
184
     */
185
    public function findTemplate($template, $themes = null)
186
    {
187
        if ($themes === null) {
188
            $themes = SSViewer::get_themes();
189
        }
190
191
        // Look for a cached result for this data set
192
        $cacheKey = md5(json_encode($template) . json_encode($themes));
193
        if ($this->getCache()->has($cacheKey)) {
194
            return $this->getCache()->get($cacheKey);
195
        }
196
197
        $type = '';
198
        if (is_array($template)) {
199
            // Check if templates has type specified
200
            if (array_key_exists('type', $template)) {
201
                $type = $template['type'];
202
                unset($template['type']);
203
            }
204
            // Templates are either nested in 'templates' or just the rest of the list
205
            $templateList = array_key_exists('templates', $template) ? $template['templates'] : $template;
206
        } else {
207
            $templateList = array($template);
208
        }
209
210
        foreach ($templateList as $i => $template) {
0 ignored issues
show
$template is overwriting one of the parameters of this function.
Loading history...
211
            // Check if passed list of templates in array format
212
            if (is_array($template)) {
213
                $path = $this->findTemplate($template, $themes);
214
                if ($path) {
215
                    $this->getCache()->set($cacheKey, $path);
216
                    return $path;
217
                }
218
                continue;
219
            }
220
221
            // If we have an .ss extension, this is a path, not a template name. We should
222
            // pass in templates without extensions in order for template manifest to find
223
            // files dynamically.
224
            if (substr($template, -3) == '.ss' && file_exists($template)) {
225
                $this->getCache()->set($cacheKey, $template);
226
                return $template;
227
            }
228
229
            // Check string template identifier
230
            $template = str_replace('\\', '/', $template);
231
            $parts = explode('/', $template);
232
233
            $tail = array_pop($parts);
234
            $head = implode('/', $parts);
235
            $themePaths = $this->getThemePaths($themes);
236
            foreach ($themePaths as $themePath) {
237
                // Join path
238
                $pathParts = [ $this->base, $themePath, 'templates', $head, $type, $tail ];
239
                try {
240
                    $path = Path::join($pathParts) . '.ss';
241
                    if (file_exists($path)) {
242
                        $this->getCache()->set($cacheKey, $path);
243
                        return $path;
244
                    }
245
                } catch (InvalidArgumentException $e) {
246
                    // No-op
247
                }
248
            }
249
        }
250
251
        // No template found
252
        $this->getCache()->set($cacheKey, null);
253
        return null;
254
    }
255
256
    /**
257
     * Resolve themed CSS path
258
     *
259
     * @param string $name Name of CSS file without extension
260
     * @param array $themes List of themes, Defaults to {@see SSViewer::get_themes()}
261
     * @return string Path to resolved CSS file (relative to base dir)
262
     */
263
    public function findThemedCSS($name, $themes = null)
264
    {
265
        if ($themes === null) {
266
            $themes = SSViewer::get_themes();
267
        }
268
269
        if (substr($name, -4) !== '.css') {
270
            $name .= '.css';
271
        }
272
273
        $filename = $this->findThemedResource("css/$name", $themes);
274
        if ($filename === null) {
0 ignored issues
show
The condition $filename === null is always false.
Loading history...
275
            $filename = $this->findThemedResource($name, $themes);
276
        }
277
278
        return $filename;
279
    }
280
281
    /**
282
     * Resolve themed javascript path
283
     *
284
     * A javascript file in the current theme path name 'themename/javascript/$name.js' is first searched for,
285
     * and it that doesn't exist and the module parameter is set then a javascript file with that name in
286
     * the module is used.
287
     *
288
     * @param string $name The name of the file - eg '/js/File.js' would have the name 'File'
289
     * @param array $themes List of themes, Defaults to {@see SSViewer::get_themes()}
290
     * @return string Path to resolved javascript file (relative to base dir)
291
     */
292
    public function findThemedJavascript($name, $themes = null)
293
    {
294
        if ($themes === null) {
295
            $themes = SSViewer::get_themes();
296
        }
297
298
        if (substr($name, -3) !== '.js') {
299
            $name .= '.js';
300
        }
301
302
        $filename = $this->findThemedResource("javascript/$name", $themes);
303
        if ($filename === null) {
0 ignored issues
show
The condition $filename === null is always false.
Loading history...
304
            $filename = $this->findThemedResource($name, $themes);
305
        }
306
307
        return $filename;
308
    }
309
310
    /**
311
     * Resolve a themed resource
312
     *
313
     * A themed resource and be any file that resides in a theme folder.
314
     *
315
     * @param string $resource A file path relative to the root folder of a theme
316
     * @param array $themes An order listed of themes to search, Defaults to {@see SSViewer::get_themes()}
317
     * @return string
318
     */
319
    public function findThemedResource($resource, $themes = null)
320
    {
321
        if ($themes === null) {
322
            $themes = SSViewer::get_themes();
323
        }
324
325
        $paths = $this->getThemePaths($themes);
326
327
        foreach ($paths as $themePath) {
328
            $relativePath = Path::join($themePath, $resource);
329
            $absolutePath = Path::join($this->base, $relativePath);
330
            if (file_exists($absolutePath)) {
331
                return $relativePath;
332
            }
333
        }
334
335
        // Resource exists in no context
336
        return null;
337
    }
338
339
    /**
340
     * Resolve all themes to the list of root folders relative to site root
341
     *
342
     * @param array $themes List of themes to resolve. Supports named theme sets. Defaults
343
     *                      to {@see SSViewer::get_themes()}.
344
     * @return array List of root-relative folders in order of precendence.
345
     */
346
    public function getThemePaths($themes = null)
347
    {
348
        if ($themes === null) {
349
            $themes = SSViewer::get_themes();
350
        }
351
352
        $paths = [];
353
        foreach ($themes as $themename) {
354
            // Expand theme sets
355
            $set = $this->getSet($themename);
356
            $subthemes = $set ? $set->getThemes() : [$themename];
357
358
            // Resolve paths
359
            foreach ($subthemes as $theme) {
360
                $paths[] = $this->getPath($theme);
361
            }
362
        }
363
        return $paths;
364
    }
365
366
    /**
367
     * Flush any cached data
368
     */
369
    public static function flush()
370
    {
371
        self::inst()->getCache()->clear();
372
    }
373
374
    /**
375
     * @return CacheInterface
376
     */
377
    public function getCache()
378
    {
379
        if (!$this->cache) {
380
            $this->setCache(Injector::inst()->get(CacheInterface::class . '.ThemeResourceLoader'));
381
        }
382
        return $this->cache;
383
    }
384
385
    /**
386
     * @param CacheInterface $cache
387
     * @return ThemeResourceLoader
388
     */
389
    public function setCache(CacheInterface $cache)
390
    {
391
        $this->cache = $cache;
392
        return $this;
393
    }
394
}
395