• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Cecilapp / Cecil / 13786562850

11 Mar 2025 11:08AM UTC coverage: 82.938% (-0.5%) from 83.394%
13786562850

push

github

web-flow
Merge pull request #2133 from Cecilapp/cache

feat: better cache and Twig cache fragments

106 of 124 new or added lines in 11 files covered. (85.48%)

23 existing lines in 6 files now uncovered.

2970 of 3581 relevant lines covered (82.94%)

0.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

51.92
/src/Assets/Cache.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <arnaud@ligny.fr>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13

14
namespace Cecil\Assets;
15

16
use Cecil\Builder;
17
use Cecil\Collection\Page\Page;
18
use Cecil\Exception\RuntimeException;
19
use Cecil\Util;
20
use Psr\SimpleCache\CacheInterface;
21

22
class Cache implements CacheInterface
23
{
24
    /** @var Builder */
25
    protected $builder;
26

27
    /** @var string */
28
    protected $cacheDir;
29

30
    /** @var int */
31
    protected $duration;
32

33
    public function __construct(Builder $builder, string $pool = '')
34
    {
35
        $this->builder = $builder;
1✔
36
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
1✔
37
        $this->duration = 31536000; // 1 year
1✔
38
    }
39

40
    /**
41
     * {@inheritdoc}
42
     */
43
    public function has($key): bool
44
    {
45
        $key = $this->sanitizeKey($key);
1✔
46
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
1✔
47
            return false;
1✔
48
        }
49

50
        return true;
1✔
51
    }
52

53
    /**
54
     * {@inheritdoc}
55
     */
56
    public function get($key, $default = null): mixed
57
    {
58
        try {
59
            $key = $this->sanitizeKey($key);
1✔
60
            // return default value if file doesn't exists
61
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
1✔
62
                return $default;
×
63
            }
64
            // unserialize data
65
            $data = unserialize($content);
1✔
66
            // check expiration
67
            if ($data['expiration'] <= time()) {
1✔
68
                $this->delete($key);
×
69

UNCOV
70
                return $default;
×
71
            }
72
            // get content from dedicated file
73
            if (\is_array($data['value']) && isset($data['value']['path'])) {
1✔
74
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
1✔
75
                    $data['value']['content'] = $content;
1✔
76
                }
77
            }
78
        } catch (\Exception $e) {
×
79
            $this->builder->getLogger()->error($e->getMessage());
×
80

81
            return $default;
×
82
        }
83

84
        return $data['value'];
1✔
85
    }
86

87
    /**
88
     * {@inheritdoc}
89
     */
90
    public function set($key, $value, $ttl = null): bool
91
    {
92
        try {
93
            $key = $this->sanitizeKey($key);
1✔
94
            $this->prune($key);
1✔
95
            // put file content in a dedicated file
96
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
1✔
97
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
1✔
98
                unset($value['content']);
1✔
99
            }
100
            // serialize data
101
            $data = serialize([
1✔
102
                'value'      => $value,
1✔
103
                'expiration' => time() + $this->duration($ttl),
1✔
104
            ]);
1✔
105
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
1✔
UNCOV
106
        } catch (\Exception $e) {
×
107
            $this->builder->getLogger()->error($e->getMessage());
×
108

109
            return false;
×
110
        }
111

112
        return true;
1✔
113
    }
114

115
    /**
116
     * {@inheritdoc}
117
     */
118
    public function delete($key): bool
119
    {
120
        try {
NEW
121
            $key = $this->sanitizeKey($key);
×
122
            Util\File::getFS()->remove($this->getFilePathname($key));
×
123
            $this->prune($key);
×
124
        } catch (\Exception $e) {
×
125
            $this->builder->getLogger()->error($e->getMessage());
×
126

127
            return false;
×
128
        }
129

130
        return true;
×
131
    }
132

133
    /**
134
     * {@inheritdoc}
135
     */
136
    public function clear(): bool
137
    {
138
        try {
139
            Util\File::getFS()->remove($this->cacheDir);
×
140
        } catch (\Exception $e) {
×
141
            $this->builder->getLogger()->error($e->getMessage());
×
142

143
            return false;
×
144
        }
145

146
        return true;
×
147
    }
148

149
    /**
150
     * {@inheritdoc}
151
     */
152
    public function getMultiple($keys, $default = null): iterable
153
    {
154
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
155
    }
156

157
    /**
158
     * {@inheritdoc}
159
     */
160
    public function setMultiple($values, $ttl = null): bool
161
    {
162
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
163
    }
164

165
    /**
166
     * {@inheritdoc}
167
     */
168
    public function deleteMultiple($keys): bool
169
    {
170
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
×
171
    }
172

173
    /**
174
     * Creates key with the MD5 hash of a string.
175
     */
176
    public function createKeyFromString(string $value, ?string $suffix = null): string
177
    {
178
        return \sprintf('%s%s__%s', hash('md5', $value), ($suffix ? '_' . $suffix : ''), $this->builder->getVersion());
1✔
179
    }
180

181
    /**
182
     * Creates key from a file: "$relativePath__MD5".
183
     *
184
     * @throws RuntimeException
185
     */
186
    public function createKeyFromPath(string $path, string $relativePath): string
187
    {
188
        if (false === $content = Util\File::fileGetContents($path)) {
1✔
189
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $path));
×
190
        }
191

192
        return $this->sanitizeKey(\sprintf('%s__%s', $relativePath, $this->createKeyFromString($content)));
1✔
193
    }
194

195
    /**
196
     * Creates key from an Asset: "$filename_$ext_$tags__VERSION__MD5".
197
     */
198
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
199
    {
200
        $tags = implode('_', $tags ?? []);
1✔
201

202
        return $this->sanitizeKey(\sprintf(
1✔
203
            '%s%s%s__%s',
1✔
204
            $asset['filename'],
1✔
205
            "_{$asset['ext']}",
1✔
206
            $tags ? "_$tags" : '',
1✔
207
            $this->createKeyFromString($asset['content'] ?? '')
1✔
208
        ));
1✔
209
    }
210

211
    /**
212
     * Clear cache by pattern.
213
     */
214
    public function clearByPattern(string $pattern): int
215
    {
216
        try {
217
            if (!Util\File::getFS()->exists($this->cacheDir)) {
×
NEW
218
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
×
219
            }
220
            $fileCount = 0;
×
221
            $iterator = new \RecursiveIteratorIterator(
×
222
                new \RecursiveDirectoryIterator($this->cacheDir),
×
223
                \RecursiveIteratorIterator::SELF_FIRST
×
224
            );
×
225
            foreach ($iterator as $file) {
×
226
                if ($file->isFile()) {
×
227
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
×
228
                        Util\File::getFS()->remove($file->getPathname());
×
229
                        $fileCount++;
×
NEW
230
                        $this->builder->getLogger()->debug(\sprintf('Cache file "%s" removed', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
×
231
                    }
232
                }
233
            }
234
        } catch (\Exception $e) {
×
235
            $this->builder->getLogger()->error($e->getMessage());
×
236

237
            return 0;
×
238
        }
239

240
        return $fileCount;
×
241
    }
242

243
    /**
244
     * Prepares and validate $key.
245
     */
246
    public function sanitizeKey(string $key): string
247
    {
248
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
1✔
249
        $key = Page::slugify($key);                            // slugify
1✔
250
        $key = trim($key, '/');                                // remove leading/trailing slashes
1✔
251
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
1✔
252
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
1✔
253

254
        return $key;
1✔
255
    }
256

257
    /**
258
     * Returns cache content file pathname from path.
259
     */
260
    public function getContentFilePathname(string $path): string
261
    {
262
        return Util::joinFile($this->builder->getConfig()->getCacheAssetsFilesPath(), $path);
1✔
263
    }
264

265
    /**
266
     * Returns cache file pathname from key.
267
     */
268
    private function getFilePathname(string $key): string
269
    {
270
        return Util::joinFile($this->cacheDir, \sprintf('%s.ser', $key));
1✔
271
    }
272

273
    /**
274
     * Removes previous cache files.
275
     */
276
    private function prune(string $key): bool
277
    {
278
        try {
279
            $keyAsArray = explode('__', $this->sanitizeKey($key));
1✔
280
            // if 3 parts (with hash), remove all files with the same first part
281
            // pattern: `path_tag__hash__version`
282
            if (!empty($keyAsArray[0]) && \count($keyAsArray) == 3) {
1✔
283
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
1✔
284
                foreach (glob($pattern) ?: [] as $filename) {
1✔
285
                    Util\File::getFS()->remove($filename);
1✔
286
                    $this->builder->getLogger()->debug(\sprintf('Cache file "%s" removed', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
1✔
287
                }
288
            }
289
        } catch (\Exception $e) {
×
290
            $this->builder->getLogger()->error($e->getMessage());
×
291

292
            return false;
×
293
        }
294

295
        return true;
1✔
296
    }
297

298
    /**
299
     * Convert the various expressions of a TTL value into duration in seconds.
300
     */
301
    protected function duration(\DateInterval|int|null $ttl): int
302
    {
303
        if ($ttl === null) {
1✔
304
            return $this->duration;
1✔
305
        }
306
        if (\is_int($ttl)) {
×
307
            return $ttl;
×
308
        }
309
        if ($ttl instanceof \DateInterval) {
×
310
            return (int)$ttl->format('%s');
×
311
        }
312

313
        throw new \InvalidArgumentException('TTL values must be one of null, int, \DateInterval');
×
314
    }
315
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc