├── roave-bc-check.yaml
├── src
├── Config
│ ├── Routes.php
│ ├── Filters.php
│ └── Registrar.php
├── Entities
│ └── Theme.php
├── Views
│ ├── select.php
│ └── form.php
├── Database
│ ├── Seeds
│ │ └── ThemeSeeder.php
│ └── Migrations
│ │ └── 20190620100802_create_table_themes.php
├── Helpers
│ └── themes_helper.php
├── Commands
│ ├── ThemesList.php
│ └── ThemesAdd.php
├── Models
│ └── ThemeModel.php
├── Controllers
│ └── Themes.php
├── ThemeBundle.php
└── Filters
│ └── ThemesFilter.php
├── infection.json.dist
├── .php-cs-fixer.dist.php
├── psalm_autoload.php
├── psalm.xml
├── composer-unused.php
├── UPGRADING.md
├── LICENSE
├── SECURITY.md
├── composer.json
├── deptrac.yaml
├── rector.php
└── README.md
/roave-bc-check.yaml:
--------------------------------------------------------------------------------
1 | parameters:
2 | ignoreErrors:
3 | - '#\[BC\] SKIPPED: .+ could not be found in the located source#'
4 |
--------------------------------------------------------------------------------
/src/Config/Routes.php:
--------------------------------------------------------------------------------
1 | post('themes/select', '\Tatter\Themes\Controllers\Themes::select');
8 |
--------------------------------------------------------------------------------
/src/Config/Filters.php:
--------------------------------------------------------------------------------
1 | aliases['themes'] = ThemesFilter::class;
12 |
--------------------------------------------------------------------------------
/src/Entities/Theme.php:
--------------------------------------------------------------------------------
1 | 'bool',
16 | ];
17 | }
18 |
--------------------------------------------------------------------------------
/infection.json.dist:
--------------------------------------------------------------------------------
1 | {
2 | "source": {
3 | "directories": [
4 | "src/"
5 | ],
6 | "excludes": [
7 | "Config",
8 | "Database/Migrations",
9 | "Views"
10 | ]
11 | },
12 | "logs": {
13 | "text": "build/infection.log"
14 | },
15 | "mutators": {
16 | "@default": true
17 | },
18 | "bootstrap": "vendor/codeigniter4/framework/system/Test/bootstrap.php"
19 | }
20 |
--------------------------------------------------------------------------------
/src/Config/Registrar.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | public static function Preferences(): array
13 | {
14 | return [
15 | 'theme' => 'Default',
16 | ];
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Views/select.php:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/src/Views/form.php:
--------------------------------------------------------------------------------
1 | name;
6 | }
7 |
8 | $data = [
9 | 'selected' => $selected,
10 | 'auto' => true,
11 | ];
12 | ?>
13 |
14 |
19 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | files()
9 | ->in([
10 | __DIR__ . '/src/',
11 | __DIR__ . '/tests/',
12 | ])
13 | ->exclude('build')
14 | ->append([__FILE__]);
15 |
16 | $overrides = [];
17 |
18 | $options = [
19 | 'finder' => $finder,
20 | 'cacheFile' => 'build/.php-cs-fixer.cache',
21 | ];
22 |
23 | return Factory::create(new CodeIgniter4(), $overrides, $options)->forProjects();
24 |
--------------------------------------------------------------------------------
/psalm_autoload.php:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/Database/Seeds/ThemeSeeder.php:
--------------------------------------------------------------------------------
1 | first()) {
18 | $themes->insert([
19 | 'name' => 'Default',
20 | 'path' => 'themes/default',
21 | 'description' => 'Default theme',
22 | ]);
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/composer-unused.php:
--------------------------------------------------------------------------------
1 | $config
11 | ->setAdditionalFilesFor('codeigniter4/framework', [
12 | ...Glob::glob(__DIR__ . '/vendor/codeigniter4/framework/system/Helpers/*.php'),
13 | ])
14 | ->setAdditionalFilesFor('tatter/preferences', [
15 | __DIR__ . '/vendor/tatter/preferences/src/Helpers/preferences_helper.php',
16 | ]);
17 |
--------------------------------------------------------------------------------
/UPGRADING.md:
--------------------------------------------------------------------------------
1 | # Upgrade Guide
2 |
3 | ## Version 1 to 2
4 | ***
5 |
6 | > Note: This is a complete refactor! Please be sure to read the docs carefully before upgrading.
7 |
8 | * Uses `Tatter\Assets` for handling its files, so the root folder for theme paths is now `config('Assets')->directory` (default: `FCPATH . 'assets/'`)
9 | * Uses `Tatter\Preferences` for managing persistent settings which strongly recommends `codeigniter4/authentication-implementation`
10 | * Asset injection is now handled by the `ThemesFilter`; remove any references to the tag views and read the docs to set up the filter
11 | * Theme settings now track based on the theme name instead of its ID so pay attention to naming
12 |
--------------------------------------------------------------------------------
/src/Helpers/themes_helper.php:
--------------------------------------------------------------------------------
1 | where('name', $name)->first()) {
22 | return $theme;
23 | }
24 |
25 | throw new RuntimeException('Unable to locate the theme: ' . $name);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Commands/ThemesList.php:
--------------------------------------------------------------------------------
1 | table('themes')->select('name, path, description, dark, created_at')
20 | ->where('deleted_at IS NULL')
21 | ->orderBy('name', 'asc')
22 | ->get()->getResultArray();
23 |
24 | if (empty($rows)) {
25 | CLI::write('No available themes.', 'yellow');
26 | } else {
27 | $thead = ['Name', 'Path', 'Description', 'Dark?', 'Created'];
28 | CLI::table($rows, $thead);
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Tatter Software
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Models/ThemeModel.php:
--------------------------------------------------------------------------------
1 | 'required|max_length[255]',
25 | 'path' => 'required|max_length[255]',
26 | ];
27 |
28 | /**
29 | * Faked data for Fabricator.
30 | */
31 | public function fake(Generator &$faker): Theme
32 | {
33 | return new Theme([
34 | 'name' => $faker->catchPhrase,
35 | 'path' => 'themes/' . $faker->word,
36 | 'description' => $faker->sentence,
37 | 'dark' => random_int(0, 1),
38 | ]);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Database/Migrations/20190620100802_create_table_themes.php:
--------------------------------------------------------------------------------
1 | ['type' => 'varchar', 'constraint' => 255],
13 | 'path' => ['type' => 'varchar', 'constraint' => 255],
14 | 'description' => ['type' => 'text', 'null' => true],
15 | 'dark' => ['type' => 'boolean', 'default' => 0],
16 | 'created_at' => ['type' => 'datetime', 'null' => true],
17 | 'updated_at' => ['type' => 'datetime', 'null' => true],
18 | 'deleted_at' => ['type' => 'datetime', 'null' => true],
19 | ];
20 |
21 | $this->forge->addField('id');
22 | $this->forge->addField($fields);
23 |
24 | $this->forge->addKey('name');
25 | $this->forge->addKey('created_at');
26 |
27 | $this->forge->createTable('themes');
28 | }
29 |
30 | public function down()
31 | {
32 | $this->forge->dropTable('themes');
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Controllers/Themes.php:
--------------------------------------------------------------------------------
1 | request->getVar('theme')) {
20 | return redirect()
21 | ->back()
22 | ->withInput()
23 | ->with('errors', ['No theme selected.']);
24 | }
25 |
26 | // Look up the theme
27 | if (! $theme = model(ThemeModel::class)->where('name', $name)->first()) {
28 | return redirect()
29 | ->back()
30 | ->withInput()
31 | ->with('errors', ['Could not find theme: ' . $name . '.']);
32 | }
33 |
34 | // Update the setting and send back
35 | preference('theme', $theme->name);
36 |
37 | return redirect()
38 | ->back()
39 | ->with('success', 'User theme changed to ' . $theme->name . '.');
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | The development team and community take all security issues seriously. **Please do not make public any uncovered flaws.**
4 |
5 | ## Reporting a Vulnerability
6 |
7 | Thank you for improving the security of our code! Any assistance in removing security flaws will be acknowledged.
8 |
9 | **Please report security flaws by emailing the development team directly: support@tattersoftware.com**.
10 |
11 | The lead maintainer will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating
12 | the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the
13 | progress towards a fix and full announcement, and may ask for additional information or guidance.
14 |
15 | ## Disclosure Policy
16 |
17 | When the security team receives a security bug report, they will assign it to a primary handler.
18 | This person will coordinate the fix and release process, involving the following steps:
19 |
20 | - Confirm the problem and determine the affected versions.
21 | - Audit code to find any potential similar problems.
22 | - Prepare fixes for all releases still under maintenance. These fixes will be released as fast as possible.
23 |
24 | ## Comments on this Policy
25 |
26 | If you have suggestions on how this process could be improved please submit a Pull Request.
27 |
--------------------------------------------------------------------------------
/src/ThemeBundle.php:
--------------------------------------------------------------------------------
1 | useCache) {
23 | // Use the hash of these items for the cache key
24 | $key = 'assets-theme' . $theme->id;
25 |
26 | // If there's a cached version then return it
27 | if ($bundle = cache($key)) {
28 | return $bundle;
29 | }
30 | }
31 |
32 | $bundle = new self();
33 |
34 | // Resolve the directory for the active theme
35 | $root = rtrim(Asset::config()->directory, '\\/ ');
36 | $directory = $root . DIRECTORY_SEPARATOR . trim($theme->path, '/ ');
37 |
38 | if (! is_dir($directory)) {
39 | throw new UnexpectedValueException('Theme directory does not exist: ' . $directory);
40 | }
41 |
42 | // Locate all CSS and JSS files in the them path
43 | $files = (new FileCollection())
44 | ->addDirectory($directory)
45 | ->retainPattern('#(.*)\.css$|(.*)\.js$#i'); // limit to .css and .js files
46 |
47 | // Create an Asset from each relative path and add it to the Bundle
48 | foreach ($files as $file) {
49 | $relativePath = str_replace($root, '', $file);
50 | $bundle->add(Asset::createFromPath($relativePath));
51 | }
52 |
53 | if (isset($key)) {
54 | cache()->save($key, $bundle);
55 | }
56 |
57 | return $bundle;
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tatter/themes",
3 | "description": "Lightweight theme manager for CodeIgniter 4",
4 | "license": "MIT",
5 | "type": "library",
6 | "keywords": [
7 | "codeigniter",
8 | "codeigniter4",
9 | "themes",
10 | "css"
11 | ],
12 | "authors": [
13 | {
14 | "name": "Matthew Gatner",
15 | "email": "mgatner@tattersoftware.com",
16 | "homepage": "https://tattersoftware.com",
17 | "role": "Developer"
18 | }
19 | ],
20 | "homepage": "https://github.com/tattersoftware/codeigniter4-themes",
21 | "require": {
22 | "php": "^7.4 || ^8.0",
23 | "tatter/assets": "^3.0",
24 | "tatter/preferences": "^1.0"
25 | },
26 | "require-dev": {
27 | "codeigniter4/framework": "^4.1",
28 | "tatter/imposter": "^1.0",
29 | "tatter/tools": "^2.0"
30 | },
31 | "minimum-stability": "dev",
32 | "prefer-stable": true,
33 | "autoload": {
34 | "psr-4": {
35 | "Tatter\\Themes\\": "src"
36 | },
37 | "exclude-from-classmap": [
38 | "**/Database/Migrations/**"
39 | ]
40 | },
41 | "autoload-dev": {
42 | "psr-4": {
43 | "Tests\\Support\\": "tests/_support"
44 | }
45 | },
46 | "config": {
47 | "allow-plugins": {
48 | "phpstan/extension-installer": true
49 | }
50 | },
51 | "scripts": {
52 | "analyze": [
53 | "phpstan analyze",
54 | "psalm",
55 | "rector process --dry-run"
56 | ],
57 | "ci": [
58 | "Composer\\Config::disableProcessTimeout",
59 | "@deduplicate",
60 | "@analyze",
61 | "@composer normalize --dry-run",
62 | "@test",
63 | "@inspect",
64 | "@style"
65 | ],
66 | "deduplicate": "phpcpd app/ src/",
67 | "inspect": "deptrac analyze --cache-file=build/deptrac.cache",
68 | "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit",
69 | "retool": "retool",
70 | "style": "php-cs-fixer fix --verbose --ansi --using-cache=no",
71 | "test": "phpunit"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Commands/ThemesAdd.php:
--------------------------------------------------------------------------------
1 | "The name of the theme (e.g. 'Dark Night')",
19 | 'path' => "The relative path of the theme (e.g. 'themes/dark')",
20 | 'description' => "A brief description of the theme (e.g. 'A stormy theme fitting for night')",
21 | 'dark' => 'Whether this theme uses dark colors (y/n)',
22 | ];
23 |
24 | public function run(array $params = [])
25 | {
26 | $config = Asset::config();
27 |
28 | // Consume or prompt for a theme name
29 | if (! $name = array_shift($params)) {
30 | $name = CLI::prompt('Name of the theme', null, 'required');
31 | }
32 |
33 | // Consume or prompt for the path
34 | if (! $path = array_shift($params)) {
35 | $path = CLI::prompt('Path to the theme, relative to ' . $config->directory, null, 'required');
36 | }
37 |
38 | // Verify theme path
39 | if (! is_dir($config->directory . $path)) {
40 | CLI::write('Warning! Directory not found: ' . $config->directory . $path, 'yellow');
41 | CLI::write('Be sure to add the directory and files before using the theme', 'yellow');
42 | }
43 |
44 | // Consume or prompt for description
45 | if (! $description = array_shift($params)) {
46 | $description = CLI::prompt('Description');
47 | }
48 |
49 | // Consume or prompt for dark status
50 | if (! $dark = array_shift($params)) {
51 | $dark = CLI::prompt('Dark theme?', ['n', 'y']);
52 | }
53 |
54 | // Try to create the record
55 | $result = model(ThemeModel::class)->save([
56 | 'name' => $name,
57 | 'path' => $path,
58 | 'description' => $description,
59 | 'dark' => ($dark === 'y'),
60 | ]);
61 |
62 | if (! $result) {
63 | $this->showError(new RuntimeException(implode(' ', model(ThemeModel::class)->errors())));
64 |
65 | return;
66 | }
67 |
68 | $this->call('themes:list');
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Filters/ThemesFilter.php:
--------------------------------------------------------------------------------
1 | getBody())) {
39 | return null;
40 | }
41 |
42 | // Check CLI separately for coverage
43 | if (is_cli() && ENVIRONMENT !== 'testing') {
44 | return null; // @codeCoverageIgnore
45 | }
46 |
47 | // Only run on HTML content
48 | if (strpos($response->getHeaderLine('Content-Type'), 'html') === false) {
49 | return null;
50 | }
51 |
52 | // If no theme was provided then load the current
53 | if (empty($arguments)) {
54 | helper(['preferences', 'settings', 'themes']);
55 | $themes = [theme()];
56 | }
57 | // Otherwise look them up by name
58 | else {
59 | $themes = [];
60 |
61 | foreach ($arguments as $name) {
62 | if ($theme = model(ThemeModel::class)->where('name', $name)->first()) {
63 | $themes[] = $theme;
64 | } else {
65 | throw new RuntimeException('Unable to locate the theme: ' . $name);
66 | }
67 | }
68 | }
69 |
70 | // Build the tag blocks
71 | $headTags = [];
72 | $bodyTags = [];
73 |
74 | foreach ($themes as $theme) {
75 | $bundle = ThemeBundle::createFromTheme($theme);
76 |
77 | $headTags[] = $bundle->head();
78 | $bodyTags[] = $bundle->body();
79 | }
80 |
81 | $headTags = implode(PHP_EOL, $headTags);
82 | $bodyTags = implode(PHP_EOL, $bodyTags);
83 |
84 | // Short circuit?
85 | // @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/7117
86 | if ($headTags === '' && $bodyTags === '') {
87 | return null;
88 | }
89 |
90 | $body = $response->getBody();
91 |
92 | // Add any head content right before the closing head tag
93 | if ($headTags !== '') {
94 | $body = str_replace('', $headTags . PHP_EOL . '', $body);
95 | }
96 | // Add any body content right before the closing body tag
97 | if ($bodyTags !== '') {
98 | $body = str_replace('