├── .github └── workflows │ └── release.yml ├── .gitignore ├── .php-cs-fixer.dist ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin └── wrangle ├── captainhook.json ├── cliff.toml ├── composer.json ├── composer.lock ├── phive.xml ├── phpstan.neon.dist ├── phpunit.xml ├── rector.php ├── scripts ├── bump.sh ├── php_cs_fixer.sh └── test_setup.sh ├── src ├── Auth │ ├── Token.php │ └── TokenInterface.php ├── Commands │ ├── JobQueueCommand.php │ └── Wrangle │ │ └── RunQueueCommand.php ├── Controllers │ ├── AbstractController.php │ └── ControllerInterface.php ├── Core │ ├── Assets.php │ ├── CoreHooks.php │ ├── Loader.php │ ├── PluginOrThemeable.php │ ├── Router.php │ └── RouterException.php ├── Database │ ├── Connection.php │ ├── MigrationInitialiser.php │ ├── Migrations.php │ ├── Migrations │ │ ├── 20201018145438_create_auth_tokens_table.php │ │ ├── 20210112174503_create_queued_jobs_table.php │ │ ├── 20210122110424_update_queued_jobs_table.php │ │ ├── 20221203173957_add_queue_name_to_queued_jobs_table.php │ │ ├── 20230320094337_create_events_table.php │ │ └── 20230614065259_add_soft_delete_to_auth_table.php │ └── phinx.default.yml ├── Enums │ └── AcfEnum.php ├── Fields │ └── FieldGroupInterface.php ├── Hooks │ ├── HookInterface.php │ ├── HookIsSet.php │ ├── JobCommandHook.php │ └── TemplateHook.php ├── Http │ ├── ArraysRequestInput.php │ ├── Handlers │ │ ├── AjaxHandler.php │ │ ├── CustomRouteHandler.php │ │ ├── HandlerInterface.php │ │ ├── HasMiddleware.php │ │ ├── RestHandler.php │ │ └── TemplateHandler.php │ ├── ResponseFactory.php │ ├── ServerRequest.php │ ├── Shutdown.php │ └── WPRest │ │ └── ServerRequest.php ├── Jobs │ ├── JobInterface.php │ ├── Queue.php │ └── Queueable.php ├── Log │ ├── LogEventHandler.php │ └── LogHandlerType.php ├── Middleware │ └── TestOnlyMiddleware.php ├── Models │ ├── Comment.php │ ├── CustomPostable.php │ ├── Event.php │ ├── MenuItem.php │ ├── Option.php │ ├── Post.php │ ├── PostMeta.php │ ├── PostSugar.php │ ├── PostTypeInterface.php │ ├── QueuedJob.php │ ├── User.php │ ├── UserMeta.php │ ├── UserSugar.php │ └── WPMenuItem.php ├── Registry │ └── RegistryInterface.php ├── Router │ ├── AltoRouter.php │ └── Strategy │ │ ├── AjaxPrivateStrategy.php │ │ ├── AjaxPublicStrategy.php │ │ ├── CustomStrategy.php │ │ ├── RestStrategy.php │ │ ├── StrategyException.php │ │ ├── StrategyFactory.php │ │ └── StrategyInterface.php ├── View │ ├── Blade │ │ ├── Blade.php │ │ ├── Directives.php │ │ └── Local.php │ ├── BladeView.php │ ├── GetsDirectory.php │ ├── LegacyPlatesView.php │ ├── Plates │ │ ├── CaptainHook │ │ │ └── Lint.php │ │ ├── Engine.php │ │ ├── Exception │ │ │ ├── FuncException.php │ │ │ ├── HydrateTemplateException.php │ │ │ ├── PlatesException.php │ │ │ ├── RenderTemplateException.php │ │ │ ├── StackException.php │ │ │ └── TemplateErrorException.php │ │ ├── Extension.php │ │ ├── Extension │ │ │ ├── Data │ │ │ │ ├── DataExtension.php │ │ │ │ └── data.php │ │ │ ├── ExtensionInterface.php │ │ │ ├── Folders │ │ │ │ ├── FoldersExtension.php │ │ │ │ └── folders.php │ │ │ ├── LayoutSections │ │ │ │ ├── DefaultLayoutRenderTemplate.php │ │ │ │ ├── LayoutRenderTemplate.php │ │ │ │ ├── LayoutSectionsExtension.php │ │ │ │ ├── Sections.php │ │ │ │ └── layout-sections.php │ │ │ ├── Path │ │ │ │ ├── PathExtension.php │ │ │ │ ├── ResolvePathArgs.php │ │ │ │ └── path.php │ │ │ └── RenderContext │ │ │ │ ├── FuncArgs.php │ │ │ │ ├── RenderContext.php │ │ │ │ ├── RenderContextExtension.php │ │ │ │ ├── func.php │ │ │ │ └── render-context.php │ │ ├── MagicResolver.php │ │ ├── PlatesExtension.php │ │ ├── RenderTemplate.php │ │ ├── RenderTemplate │ │ │ ├── ComposeRenderTemplate.php │ │ │ ├── FileSystemRenderTemplate.php │ │ │ ├── MapContentRenderTemplate.php │ │ │ ├── MockRenderTemplate.php │ │ │ ├── PhpRenderTemplate.php │ │ │ ├── RenderTemplateDecorator.php │ │ │ ├── StaticFileRenderTemplate.php │ │ │ └── ValidatePathRenderTemplate.php │ │ ├── Template.php │ │ ├── Template │ │ │ └── match.php │ │ ├── TemplateReference.php │ │ ├── Util │ │ │ ├── Container.php │ │ │ └── util.php │ │ └── Validator.php │ ├── PlatesView.php │ ├── TwigAdhocFunction.php │ ├── TwigExtension.php │ ├── TwigView.php │ └── ViewInterface.php └── helpers.php ├── stubs ├── db.php └── stan-constants.php ├── tests ├── Integration │ ├── Auth │ │ └── TokenTest.php │ ├── ContainerHelpersTest.php │ ├── Core │ │ ├── DistAssetsTest.php │ │ ├── LoaderTest.php │ │ ├── StaticAssetsTest.php │ │ └── hooks_test.yaml │ ├── Hooks │ │ └── HookIsSetTest.php │ ├── Jobs │ │ └── QueueTest.php │ └── Router │ │ └── StrategyFactoryTest.php ├── Pest.php ├── Unit │ └── ExampleTest.php ├── bootstrap.php └── test-config.php └── wp-cli.yml /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | fetch-depth: 0 13 | - name: Generate a changelog 14 | uses: orhun/git-cliff-action@v2 15 | id: changelog 16 | with: 17 | args: --latest --strip all 18 | env: 19 | OUTPUT: CHANGES.md 20 | - name: Set env 21 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 22 | - name: Install Hub 23 | run: sudo apt-get update && sudo apt-get install -y hub 24 | - name: Create a Release 25 | run: hub release create -m "${{ env.RELEASE_VERSION }}" -m "${{ steps.changelog.outputs.content }}" "${{ env.RELEASE_VERSION }}" 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | node_modules 3 | .php_cs.cache 4 | .php-cs-fixer.cache 5 | phpstan.neon 6 | .phpunit.result.cache 7 | tools 8 | wp-test 9 | tests/coverage 10 | phinx.yml 11 | logs 12 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist: -------------------------------------------------------------------------------- 1 | in(__DIR__ . '/src') 5 | ; 6 | 7 | $config = new PhpCsFixer\Config(); 8 | 9 | return $config->setRules([ 10 | '@Symfony' => true, 11 | 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], 12 | 'blank_line_after_opening_tag' => false, 13 | 'phpdoc_no_package' => false, 14 | 'phpdoc_to_comment' => false, 15 | 'concat_space' => ['spacing' => 'one'], 16 | 'binary_operator_spaces' => ['default' => 'align'], 17 | 'increment_style' => ['style' => 'post'], 18 | 'no_leading_import_slash' => false, 19 | 'blank_line_between_import_groups' => false, 20 | 'global_namespace_import' => ['import_classes' => true], 21 | ]) 22 | ->setFinder($finder) 23 | ; 24 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bmewburn.vscode-intelephense-client", 4 | "breezelin.phpstan", 5 | "sonarsource.sonarlint-vscode", 6 | "emeraldwalk.runonsave" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "phpstan.configuration": "phpstan.neon", 3 | "phpstan.level": "config", 4 | "emeraldwalk.runonsave": { 5 | "commands": [ 6 | { 7 | "match": "(?

2 | 3 | # Forme WordPress Framework 4 | 5 | Forme is an MVC framework for WordPress. This is the core Forme Framework library, which is used by the Plugin, Theme and CodeGen components. 6 | 7 | [Click here for Documentation](https://formewp.github.io) 8 | 9 | ## Development 10 | 11 | For development run `phive install --force-accept-unsigned` followed by `composer install`. 12 | 13 | Tools are in `./tools` rather than `./vendor/bin` 14 | 15 | You also need [git cliff](https://github.com/orhun/git-cliff) for generating changelogs and [pcov](https://github.com/krakjoe/pcov) to generate coverage stats for infection to measure against. 16 | 17 | The useful ones are set up as composer scripts. Tests should run automatically on commit. 18 | 19 | ```sh 20 | composer test # run pest 21 | composer test:setup # set up WP installation for integration testing 22 | composer stan # run phpstan on src 23 | composer rector:check # rector dry run on src 24 | composer rector:fix # rector on src 25 | composer cs:check # php cs fixer dry run on src 26 | composer cs:fix # php cs fixer on src 27 | composer changelog # run git cliff 28 | composer hooks # install git hooks (will run on composer install automatically) 29 | composer bump:version # bump to the next patch version - can also take argument "minor" or "major" 30 | composer infection # run infection on src 31 | composer infection:log # run infection on src and log to infection.html 32 | ``` 33 | -------------------------------------------------------------------------------- /bin/wrangle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add($command); 25 | } 26 | } 27 | $application->run(); 28 | -------------------------------------------------------------------------------- /captainhook.json: -------------------------------------------------------------------------------- 1 | { 2 | "commit-msg": { 3 | "enabled": true, 4 | "actions": [ 5 | { 6 | "action": "\\Ramsey\\CaptainHook\\ValidateConventionalCommit", 7 | "options": { 8 | "config": { 9 | "types": [ 10 | "feat", 11 | "fix", 12 | "docs", 13 | "style", 14 | "refactor", 15 | "perf", 16 | "test", 17 | "chore", 18 | "dev" 19 | ], 20 | "typeCase": "lower", 21 | "scopeCase": "lower" 22 | } 23 | } 24 | } 25 | ] 26 | }, 27 | "pre-push": { 28 | "enabled": false, 29 | "actions": [] 30 | }, 31 | "pre-commit": { 32 | "enabled": true, 33 | "actions": [ 34 | { 35 | "action": "\\CaptainHook\\App\\Hook\\PHP\\Action\\Linting", 36 | "options": [] 37 | }, 38 | { 39 | "action": "composer test", 40 | "options": [] 41 | }, 42 | { 43 | "action": "./scripts/php_cs_fixer.sh", 44 | "options": [] 45 | } 46 | ] 47 | }, 48 | "prepare-commit-msg": { 49 | "enabled": true, 50 | "actions": [ 51 | { 52 | "action": "\\Ramsey\\CaptainHook\\PrepareConventionalCommit", 53 | "options": { 54 | "config": { 55 | "types": [ 56 | "feat", 57 | "fix", 58 | "docs", 59 | "style", 60 | "refactor", 61 | "perf", 62 | "test", 63 | "chore", 64 | "dev" 65 | ], 66 | "typeCase": "lower", 67 | "scopeCase": "lower" 68 | } 69 | } 70 | } 71 | ] 72 | }, 73 | "post-commit": { 74 | "enabled": false, 75 | "actions": [] 76 | }, 77 | "post-merge": { 78 | "enabled": false, 79 | "actions": [] 80 | }, 81 | "post-checkout": { 82 | "enabled": false, 83 | "actions": [] 84 | }, 85 | "post-rewrite": { 86 | "enabled": false, 87 | "actions": [] 88 | }, 89 | "post-change": { 90 | "enabled": false, 91 | "actions": [] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.1.0) 2 | 3 | [changelog] 4 | # changelog header 5 | header = """ 6 | # Changelog\n 7 | All notable changes to this project will be documented in this file.\n 8 | """ 9 | # template for the changelog body 10 | # https://tera.netlify.app/docs/#introduction 11 | body = """ 12 | {% if version %}\ 13 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 14 | {% else %}\ 15 | ## [unreleased] 16 | {% endif %}\ 17 | {% for group, commits in commits | group_by(attribute="group") %} 18 | ### {{ group | upper_first }} 19 | {% for commit in commits %} 20 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 21 | {% endfor %} 22 | {% endfor %}\n 23 | """ 24 | # remove the leading and trailing whitespace from the template 25 | trim = true 26 | # changelog footer 27 | footer = """ 28 | 29 | """ 30 | 31 | [git] 32 | # parse the commits based on https://www.conventionalcommits.org 33 | conventional_commits = true 34 | # filter out the commits that are not conventional 35 | filter_unconventional = true 36 | # regex for parsing and grouping commits 37 | commit_parsers = [ 38 | {message = "^feat", group = "Features"}, 39 | {message = "^fix", group = "Bug Fixes"}, 40 | {message = "^doc", group = "Documentation"}, 41 | {message = "^dev", group = "Dev Worflow"}, 42 | {message = "^perf", group = "Performance"}, 43 | {message = "^refactor", group = "Refactor"}, 44 | {message = "^style", group = "Styling"}, 45 | {message = "^test", group = "Testing"}, 46 | {message = "^chore\\(release\\): prepare for", skip = true}, 47 | {message = "^chore", group = "Miscellaneous Tasks"}, 48 | {body = ".*security", group = "Security"}, 49 | ] 50 | # filter out the commits that are not matched by commit parsers 51 | filter_commits = false 52 | # glob pattern for matching git tags 53 | tag_pattern = "v[0-9]*" 54 | # regex for skipping tags 55 | skip_tags = "v0.1.0-beta.1" 56 | # regex for ignoring tags 57 | ignore_tags = "" 58 | # sort the tags chronologically 59 | date_order = false 60 | # sort the commits inside sections by oldest/newest order 61 | sort_commits = "oldest" 62 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forme/framework", 3 | "description": "An MVC framework for WordPress.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Moussa Clarke", 8 | "email": "moussaclarke@gmail.com" 9 | } 10 | ], 11 | "require": { 12 | "php": "^8.1", 13 | "cakephp/core": "^5.0", 14 | "http-interop/response-sender": "^1.0", 15 | "illuminate/config": "^10.0 || ^11.0", 16 | "illuminate/database": "^10.0 || ^11.0", 17 | "illuminate/view": "^10.0 || ^11.0", 18 | "laminas/laminas-diactoros": "^3.0", 19 | "laravel/prompts": "^0.3.5", 20 | "league/plates": "^3.5", 21 | "log1x/sage-directives": "^2.0", 22 | "monolog/monolog": "^2.9 || ^3.3", 23 | "papertower/wp-rest-api-psr7": "^0.8.0", 24 | "php-di/php-di": "^7.0.2", 25 | "ramsey/uuid": "^4.7.4", 26 | "rareloop/psr7-server-request-extension": "^2.1", 27 | "relay/relay": "^3.0", 28 | "robmorgan/phinx": "^0.16", 29 | "spatie/enum": "^3.13", 30 | "symfony/string": "^6.4 || ^7.2", 31 | "symfony/yaml": "^6.4 || ^7.2", 32 | "twig/twig": "^3.14", 33 | "vlucas/phpdotenv": "^5.5" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Forme\\Framework\\": "src/" 38 | }, 39 | "files": [ 40 | "src/helpers.php", 41 | "src/View/Plates/Template/match.php", 42 | "src/View/Plates/Extension/Data/data.php", 43 | "src/View/Plates/Extension/Path/path.php", 44 | "src/View/Plates/Extension/RenderContext/func.php", 45 | "src/View/Plates/Extension/RenderContext/render-context.php", 46 | "src/View/Plates/Extension/LayoutSections/layout-sections.php", 47 | "src/View/Plates/Extension/Folders/folders.php", 48 | "src/View/Plates/Util/util.php", 49 | "src/Commands/Wrangle/RunQueueCommand.php" 50 | ] 51 | }, 52 | "require-dev": { 53 | "fakerphp/faker": "^1.23", 54 | "filp/whoops": "^2.15.2", 55 | "mockery/mockery": "^1.6.2", 56 | "nunomaduro/mock-final-classes": "dev-master", 57 | "pestphp/pest": "^3.0", 58 | "php-stubs/acf-pro-stubs": "^6.0.6", 59 | "php-stubs/woocommerce-stubs": "^9.5", 60 | "php-stubs/wp-cli-stubs": "^2.8", 61 | "phpstan/phpstan": "^2.1.6", 62 | "ramsey/conventional-commits": "^1.5", 63 | "rector/rector": "^2.0", 64 | "symfony/var-dumper": "^7.2", 65 | "szepeviktor/phpstan-wordpress": "^2.0" 66 | }, 67 | "repositories": [ 68 | { 69 | "type": "vcs", 70 | "url": "https://github.com/moussaclarke/wp-pest-integration-test-setup.git" 71 | } 72 | ], 73 | "scripts": { 74 | "test": "./tools/pest", 75 | "test:setup": "./scripts/test_setup.sh", 76 | "stan": "./tools/phpstan", 77 | "rector:check": "./tools/rector process src --dry-run", 78 | "rector:fix": "./tools/rector process src", 79 | "cs:check": "./tools/php-cs-fixer fix --config ./.php-cs-fixer.dist --dry-run --diff", 80 | "cs:fix": "./tools/php-cs-fixer fix --config ./.php-cs-fixer.dist --diff", 81 | "changelog": "git cliff -o CHANGELOG.md", 82 | "hooks": "./tools/captainhook install -f", 83 | "post-install-cmd": "@hooks", 84 | "bump:version": "./scripts/bump.sh", 85 | "infection": "./tools/infection -s", 86 | "infection:log": "./tools/infection --logger-html=infection.html" 87 | }, 88 | "config": { 89 | "bin-dir": "tools", 90 | "sort-packages": true, 91 | "allow-plugins": { 92 | "pestphp/pest-plugin": true, 93 | "composer/installers": true 94 | } 95 | }, 96 | "minimum-stability": "dev", 97 | "prefer-stable": true, 98 | "bin": [ 99 | "bin/wrangle" 100 | ] 101 | } 102 | -------------------------------------------------------------------------------- /phive.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | # Copy to phpstan.neon and edit according to your specific setup 2 | includes: 3 | - vendor/szepeviktor/phpstan-wordpress/extension.neon 4 | parameters: 5 | level: 6 6 | bootstrapFiles: 7 | - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php 8 | - vendor/php-stubs/acf-pro-stubs/acf-pro-stubs.php 9 | - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php 10 | - stubs/stan-constants.php 11 | dynamicConstantNames: 12 | - WP_ENV 13 | - DB_COLLATE 14 | ignoreErrors: 15 | - "#Unsafe usage of new static#" 16 | - 17 | message: '#^Undefined variable: \$this$#' 18 | path: tests/* 19 | checkMissingIterableValueType: false 20 | paths: 21 | - ./src 22 | - ./tests 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests/Unit/ 10 | 11 | 12 | ./tests/Integration/ 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 11 | __DIR__ . '/src', 12 | ]); 13 | 14 | $rectorConfig->bootstrapFiles([ 15 | __DIR__ . '/vendor/php-stubs/wordpress-stubs/wordpress-stubs.php', 16 | ]); 17 | 18 | // define sets of rules 19 | $rectorConfig->sets([ 20 | LevelSetList::UP_TO_PHP_80, 21 | SetList::CODE_QUALITY, 22 | SetList::CODING_STYLE, 23 | SetList::DEAD_CODE, 24 | ]); 25 | 26 | $rectorConfig->skip([ 27 | // skip deprecated classes 28 | __DIR__ . '/src/Core/TemplateHandler.php', 29 | __DIR__ . '/src/Core/BootstrapNavWalker.php', 30 | __DIR__ . '/src/Core/LegacyBootstrapNavWalker.php', 31 | __DIR__ . '/src/Core/ViewProvider.php', 32 | __DIR__ . '/src/Core/View.php', 33 | // skip var annotation rule since it flags where we use it to coerce variable types 34 | RemoveNonExistingVarAnnotationRector::class, 35 | ]); 36 | }; 37 | -------------------------------------------------------------------------------- /scripts/bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #Get the highest tag number 4 | CURRENT_VERSION=`git describe --abbrev=0 --tags` 5 | # remove the v from the front 6 | CURRENT_VERSION=${CURRENT_VERSION#v} 7 | # set it to 0.0.0 if it's empty 8 | CURRENT_VERSION=${CURRENT_VERSION:-'0.0.0'} 9 | 10 | #Get number parts 11 | MAJOR="${CURRENT_VERSION%%.*}"; CURRENT_VERSION="${CURRENT_VERSION#*.}" 12 | MINOR="${CURRENT_VERSION%%.*}"; CURRENT_VERSION="${CURRENT_VERSION#*.}" 13 | PATCH="${CURRENT_VERSION%%.*}"; CURRENT_VERSION="${CURRENT_VERSION#*.}" 14 | 15 | #Increase version number depending on the scope argument, default to patch 16 | case $1 in 17 | major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0;; 18 | minor) MINOR=$((MINOR+1)); PATCH=0;; 19 | patch) PATCH=$((PATCH+1)); ;; 20 | *) PATCH=$((PATCH+1)); ;; 21 | esac 22 | 23 | #Get current hash and see if it already has a tag 24 | GIT_COMMIT=`git rev-parse HEAD` 25 | HAS_TAG=`git describe --contains $GIT_COMMIT 2>/dev/null` 26 | 27 | #Create new tag 28 | NEW_VERSION="$MAJOR.$MINOR.$PATCH" 29 | 30 | # Only tag if no tag already 31 | if [ -z "$HAS_TAG" ]; then 32 | echo "Updating version to $NEW_VERSION" 33 | # if git cliff is installed Generate a changelog 34 | if command -v git-cliff &> /dev/null 35 | then 36 | echo "Generating changelog with git cliff" 37 | git cliff -o CHANGELOG.md -t $NEW_VERSION 38 | git add CHANGELOG.md 39 | else 40 | echo "We couldn't find git cliff so skipping changelog. You can install it with 'brew install git-cliff' or 'cargo install git-cliff'" 41 | echo "Then run 'git cliff --init' in the project directory" 42 | fi 43 | echo "Committing new version changes and pushing" 44 | git commit -a -m "chore: bump version" 45 | git push 46 | echo "Tagging with v$NEW_VERSION and pushing" 47 | git tag "v$NEW_VERSION" 48 | git push --tags 49 | else 50 | echo "There is already a tag on the current commit - abandoning bump" 51 | exit 1 52 | fi 53 | -------------------------------------------------------------------------------- /scripts/php_cs_fixer.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "php-cs-fixer pre commit hook start" 4 | 5 | PHP_CS_FIXER="./tools/php-cs-fixer" 6 | HAS_PHP_CS_FIXER=false 7 | 8 | if command -v $PHP_CS_FIXER &> /dev/null 9 | then 10 | HAS_PHP_CS_FIXER=true 11 | fi 12 | 13 | # exclude plate files 14 | if $HAS_PHP_CS_FIXER; then 15 | FILES=`git diff --cached --name-only --diff-filter=AM | grep '\.php' | sed '/plate/d'` 16 | if [ -z "$FILES" ] 17 | then 18 | echo "No php files needing cs checks in this commit." 19 | else 20 | $PHP_CS_FIXER fix --verbose --config=.php-cs-fixer.dist ${FILES} 21 | git add ${FILES} 22 | fi 23 | else 24 | echo "" 25 | echo "Please install php-cs-fixer before committing, e.g.:" 26 | echo "" 27 | echo "phive install php-cs-fixer" 28 | echo "" 29 | exit 1 30 | fi 31 | 32 | echo "php-cs-fixer pre commit hook finish" 33 | -------------------------------------------------------------------------------- /scripts/test_setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # check if wp-test directory exists first and if so then abort 4 | if [ -d "wp-test" ]; then 5 | echo "The wp-test directory already exists" 6 | exit 0 7 | fi 8 | 9 | # also make sure that wp cli is installed 10 | WPCLI_EXISTS='command -v wp' 11 | if ! $WPCLI_EXISTS &> /dev/null 12 | then 13 | echo "You need to install wp-cli globally to run the configuration script" 14 | exit 0 15 | fi 16 | 17 | # install forme base feeding via composer create-project with --no-script so it doesn't run all the scripts 18 | composer create-project forme/base wp-test --no-scripts 19 | 20 | # then run composer install-wordpress 21 | cd wp-test 22 | composer install-wordpress 23 | cd .. 24 | 25 | pwd=`pwd` 26 | 27 | # then run wp config create manually setting dbname, dbuser, dbpass and dbprefix 28 | wp config create --dbname=$(pwd)"/wp-test/testing" --dbuser=dbuser --dbpass=password --dbprefix=wptests_ --skip-check 29 | 30 | # wp config set constants WP_ENV, FORME_PRIVATE_ROOT, DB_DIR, DB_FILE, DISABLE_WP_CRON 31 | wp config set WP_ENV testing 32 | wp config set FORME_PRIVATE_ROOT $(pwd)"/" 33 | wp config set DB_DIR $(pwd)"/wp-test/" 34 | wp config set DB_FILE testing.sqlite3 35 | wp config set DISABLE_WP_CRON true --raw 36 | 37 | # requires & wrap forme_private_root in if statements 38 | file="wp-test/public/wp-config.php" 39 | # if sed --version succceds then this is probably a linux system 40 | if sed --version >/dev/null 2>&1; then 41 | sed -i '/\/\* That'\''s all, stop editing\! Happy publishing\. \*\//a\ 42 | \ 43 | require_once FORME_PRIVATE_ROOT.'"'"'/vendor/autoload.php'"'"';\ 44 | ' $file 45 | 46 | sed -i '/define( '\''FORME_PRIVATE_ROOT'\''/i\ 47 | \ 48 | if (!defined('"'"'FORME_PRIVATE_ROOT'"'"')) {\ 49 | ' $file 50 | 51 | sed -i '/define( '\''FORME_PRIVATE_ROOT'\''/a\ 52 | }\ 53 | ' $file 54 | # otherwise this is probably a mac, we need to add '' because of ancient sed 55 | else 56 | sed -i '' '/\/\* That'\''s all, stop editing\! Happy publishing\. \*\//a\ 57 | \ 58 | require_once FORME_PRIVATE_ROOT.'"'"'/vendor/autoload.php'"'"';\ 59 | ' $file 60 | 61 | sed -i '' '/define( '\''FORME_PRIVATE_ROOT'\''/i\ 62 | \ 63 | if (!defined('"'"'FORME_PRIVATE_ROOT'"'"')) {\ 64 | ' $file 65 | 66 | sed -i '' '/define( '\''FORME_PRIVATE_ROOT'\''/a\ 67 | }\ 68 | ' $file 69 | fi 70 | echo "Success: Require autoload and test bootstrap files" 71 | 72 | # run composer init-dot-env 73 | cd wp-test 74 | composer init-dot-env 75 | cd .. 76 | 77 | # copy over db.php files 78 | cp stubs/db.php wp-test/public/wp-content/db.php 79 | 80 | # wp core install 81 | wp core install --url="http://localhost:8000" --title="Test Site" --admin_user="admin" --admin_password="p455w0rd" --admin_email="moussaclarke@gmail.com" 82 | -------------------------------------------------------------------------------- /src/Auth/Token.php: -------------------------------------------------------------------------------- 1 | getFromDatabase($name); 19 | if ($result === null) { 20 | $result = $this->create($name, $expire); 21 | } 22 | 23 | return $result->token; 24 | } 25 | 26 | public function validate(string $token, string $name): bool 27 | { 28 | $result = $this->getFromDatabase($name); 29 | 30 | return $result && $result->token === $token; 31 | } 32 | 33 | public function destroy(string $name): void 34 | { 35 | Capsule::table('forme_auth_tokens') 36 | ->where('name', '=', $name) 37 | ->where('deleted_at', '=', null) 38 | ->update(['deleted_at' => Carbon::now()->format(self::DATE_FORMAT)]); 39 | } 40 | 41 | public function expires(string $name): ?Carbon 42 | { 43 | $result = $this->getFromDatabase($name); 44 | 45 | return $result ? Carbon::parse($result->expiry) : null; 46 | } 47 | 48 | /** 49 | * @return Model|object|static|null 50 | */ 51 | private function getFromDatabase(string $name) 52 | { 53 | $this->purge(); 54 | 55 | return Capsule::table('forme_auth_tokens') 56 | ->where('name', '=', $name) 57 | ->where('deleted_at', '=', null) 58 | ->first(); 59 | } 60 | 61 | /** 62 | * @return Model|object|static|null 63 | */ 64 | private function create(string $name, string $expire) 65 | { 66 | $token = Uuid::uuid4(); 67 | $expiry = strtotime($expire, time()); 68 | $dt = Carbon::now(); 69 | $dt->setTimestamp($expiry); 70 | 71 | $id = Capsule::table('forme_auth_tokens')->insertGetId( 72 | ['name' => $name, 'token' => $token, 'expiry' => $dt->format(self::DATE_FORMAT)] 73 | ); 74 | 75 | return Capsule::table('forme_auth_tokens') 76 | ->where('id', '=', $id) 77 | ->first(); 78 | } 79 | 80 | /** 81 | * Purge expired tokens. 82 | */ 83 | private function purge(): void 84 | { 85 | $dt = Carbon::now(); 86 | Capsule::table('forme_auth_tokens') 87 | ->where('expiry', '<=', $dt->format(self::DATE_FORMAT)) 88 | ->where('deleted_at', '=', null) 89 | ->update(['deleted_at' => $dt->format(self::DATE_FORMAT)]); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Auth/TokenInterface.php: -------------------------------------------------------------------------------- 1 | container = getContainer(); 25 | $this->queue = $this->container->get(Queue::class); 26 | } 27 | 28 | /** 29 | * Spec: It should 30 | * run the next job in the queue 31 | * return feedback response. 32 | */ 33 | public function __invoke(array $args = []): void 34 | { 35 | $response = $this->queue->next($args[0] ?? null); 36 | WP_CLI::success($response); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Commands/Wrangle/RunQueueCommand.php: -------------------------------------------------------------------------------- 1 | addArgument(name: 'name', mode: InputArgument::OPTIONAL, description: 'The queue name'); 22 | $this->setDescription('Run the next job in the queue.'); 23 | $this->setHelp("This command will run the next job in the queue. You can specify a queue name, otherwise it will run the default queue."); 24 | } 25 | 26 | public function execute(InputInterface $input, OutputInterface $output): int 27 | { 28 | $queueName = $input->getArgument('name') ?? null; 29 | 30 | $response = getInstance(Queue::class)->next($queueName); 31 | info($response); 32 | 33 | return Command::SUCCESS; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Controllers/AbstractController.php: -------------------------------------------------------------------------------- 1 | __invoke($request); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Controllers/ControllerInterface.php: -------------------------------------------------------------------------------- 1 | template->maybeAdd(); 20 | $this->command->maybeAdd(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Core/Loader.php: -------------------------------------------------------------------------------- 1 | container = getContainer(); 23 | } 24 | 25 | /** 26 | * Adds an action to be registered with WordPress. 27 | */ 28 | public function addAction(string $actionName, string $class, string $method, int $priority = 10, int $numberOfArgs = 1): void 29 | { 30 | $this->actions[] = $this->convert($actionName, $class, $method, $priority, $numberOfArgs); 31 | } 32 | 33 | /** 34 | * Adds a filter to be registered with WordPress. 35 | */ 36 | public function addFilter(string $filterName, string $class, string $method, int $priority = 10, int $numberOfArgs = 1): void 37 | { 38 | $this->filters[] = $this->convert($filterName, $class, $method, $priority, $numberOfArgs); 39 | } 40 | 41 | /** 42 | * Register all the actions and filters within a suitably formatted config yaml. 43 | */ 44 | public function addConfig(string $yaml): void 45 | { 46 | $config = Yaml::parse($yaml); 47 | if (isset($config['actions'])) { 48 | foreach ($config['actions'] as $action) { 49 | $method = $action['method'] ?? 'default'; 50 | $priority = $action['priority'] ?? 10; 51 | $arguments = $action['arguments'] ?? 1; 52 | $this->addAction($action['hook'], $action['class'], $method, $priority, $arguments); 53 | } 54 | } 55 | 56 | if (isset($config['filters'])) { 57 | foreach ($config['filters'] as $filter) { 58 | $method = $filter['method'] ?? 'default'; 59 | $priority = $filter['priority'] ?? 10; 60 | $arguments = $filter['arguments'] ?? 1; 61 | $this->addFilter($filter['hook'], $filter['class'], $method, $priority, $arguments); 62 | } 63 | } 64 | } 65 | 66 | private function convert(string $hook, string $class, string $method, int $priority, int $numberOfArgs): array 67 | { 68 | if ($method === 'default') { 69 | // for backwards compatibility we need to check if the the class has register method 70 | $rc = new ReflectionClass($class); 71 | $method = $rc->hasMethod('register') ? 'register' : '__invoke'; 72 | } 73 | 74 | return [ 75 | 'hook' => $hook, 76 | 'resolvedCallable' => $method === '__invoke' ? $this->container->get($class) : [$this->container->get($class), $method], 77 | 'priority' => $priority, 78 | 'numberOfArgs' => $numberOfArgs, 79 | ]; 80 | } 81 | 82 | /** 83 | * Register user and core hooks with WordPress. 84 | */ 85 | public function run(): void 86 | { 87 | foreach ($this->filters as $hook) { 88 | add_filter($hook['hook'], $hook['resolvedCallable'], $hook['priority'], $hook['numberOfArgs']); 89 | } 90 | 91 | foreach ($this->actions as $hook) { 92 | add_action($hook['hook'], $hook['resolvedCallable'], $hook['priority'], $hook['numberOfArgs']); 93 | } 94 | 95 | $coreHooks = $this->container->get(CoreHooks::class); 96 | $coreHooks->load(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Core/PluginOrThemeable.php: -------------------------------------------------------------------------------- 1 | getFileName() ?: ''; 26 | } 27 | 28 | /** 29 | * Resolve plugin path 30 | * This is fairly brittle, we are assuming standard structure 31 | * And that the child class extending this is always in plugin-dir/app/Core. 32 | */ 33 | protected static function getPluginPath(): string 34 | { 35 | $realClassDir = dirname(static::getFileName(), 3); 36 | $baseName = basename($realClassDir); 37 | 38 | return realpath(WP_PLUGIN_DIR . '/' . $baseName) ?: ''; 39 | } 40 | 41 | /** 42 | * Resolve theme path. 43 | */ 44 | protected static function getThemePath(): string 45 | { 46 | return realpath(get_template_directory()) ?: ''; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Core/Router.php: -------------------------------------------------------------------------------- 1 | get(StrategyFactory::class)->get($type); 25 | // if the handler is a class we should get it via the container otherwise it won't autowire 26 | if (is_string($handler) && class_exists($handler)) { 27 | $handler = method_exists($handler, 'handle') ? [$handler, 'handle'] : $container->get($handler); 28 | } 29 | 30 | if (is_array($handler) && class_exists($handler[0])) { 31 | $handler[0] = $container->get($handler[0]); 32 | } 33 | 34 | if (!is_callable($handler)) { 35 | throw new RouterException('Not a callable handler'); 36 | } 37 | 38 | static::$currentHandler = $strategy->convert($route, $handler, $method); 39 | 40 | return new self(); 41 | } 42 | 43 | public static function get(string $route, callable|string|array $handler, string $type = 'custom'): static 44 | { 45 | return self::map($route, $handler, $type, 'GET'); 46 | } 47 | 48 | public static function post(string $route, callable|string|array $handler, string $type = 'custom'): static 49 | { 50 | return self::map($route, $handler, $type, 'POST'); 51 | } 52 | 53 | public static function put(string $route, callable|string|array $handler, string $type = 'custom'): static 54 | { 55 | return self::map($route, $handler, $type, 'PUT'); 56 | } 57 | 58 | public static function patch(string $route, callable|string|array $handler, string $type = 'custom'): static 59 | { 60 | return self::map($route, $handler, $type, 'PATCH'); 61 | } 62 | 63 | public static function delete(string $route, callable|string|array $handler, string $type = 'custom'): static 64 | { 65 | return self::map($route, $handler, $type, 'DELETE'); 66 | } 67 | 68 | public static function addMiddleware(MiddlewareInterface|string $value): static 69 | { 70 | static::$currentHandler->addMiddleware($value); 71 | 72 | return new self(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Core/RouterException.php: -------------------------------------------------------------------------------- 1 | 'sqlite', 21 | 'database' => DB_DIR . DB_FILE, 22 | ]; 23 | } else { 24 | $args = [ 25 | 'driver' => 'mysql', 26 | 'host' => DB_HOST, 27 | 'database' => DB_NAME, 28 | 'username' => DB_USER, 29 | 'password' => DB_PASSWORD, 30 | 'charset' => DB_CHARSET, 31 | ]; 32 | if (DB_COLLATE) { 33 | $args['collation'] = DB_COLLATE; 34 | } 35 | } 36 | $args['prefix'] = $this->getPrefix(); 37 | 38 | $this->capsule->addConnection($args); 39 | 40 | // Make this Capsule instance available globally via static methods 41 | $this->capsule->setAsGlobal(); 42 | 43 | // Setup the Eloquent ORM 44 | $this->capsule->bootEloquent(); 45 | } 46 | 47 | protected function getPrefix(): string 48 | { 49 | global $wpdb; 50 | if ($wpdb) { 51 | return $wpdb->prefix ?: 'wp_'; 52 | } else { 53 | $configLocation = ABSPATH . 'wp-config.php'; 54 | 55 | return configExtract('table_prefix', $configLocation) ?: 'wp_'; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Database/MigrationInitialiser.php: -------------------------------------------------------------------------------- 1 | defaultConfigLocation = realpath(__DIR__ . '/phinx.default.yml'); 16 | } 17 | 18 | /** 19 | * Creates a phinx file if it doesn't exist yet. 20 | */ 21 | public function maybeCreate(string $configLocation): void 22 | { 23 | if (!file_exists($configLocation)) { 24 | // grab the default 25 | $defaultConfig = Yaml::parse(file_get_contents($this->defaultConfigLocation)); 26 | // save the config 27 | $yaml = Yaml::dump($defaultConfig); 28 | file_put_contents($configLocation, $yaml); 29 | $this->logger->info('Creating phinx file at ' . $configLocation); 30 | } 31 | } 32 | 33 | public function setEnvironments(string $configLocation): void 34 | { 35 | global $wpdb; 36 | 37 | $phinxConfig = Yaml::parse(file_get_contents($configLocation)); 38 | $originalConfig = $phinxConfig; 39 | // make sure the default environment matches the set environment, if wp_env not set then assume development 40 | $defaultEnvironment = $phinxConfig['environments']['default_environment']; 41 | if ($defaultEnvironment != WP_ENV) { 42 | $phinxConfig['environments']['default_environment'] = WP_ENV ?: 'development'; 43 | $defaultEnvironment = $phinxConfig['environments']['default_environment']; 44 | $this->logger->info('Setting the Phinx db environment to ' . $defaultEnvironment); 45 | } 46 | 47 | // if db credentials do not match, let's set them - host/port//name/user/pass/charset 48 | $dbCredentials = $phinxConfig['environments'][$defaultEnvironment]; 49 | if ( 50 | ($dbCredentials['host'] != DB_HOST && $dbCredentials['host'] . ':' . $dbCredentials['port'] != DB_HOST) || 51 | $dbCredentials['name'] != DB_NAME || 52 | $dbCredentials['user'] != DB_USER || 53 | $dbCredentials['pass'] != DB_PASSWORD || 54 | $dbCredentials['charset'] != DB_CHARSET 55 | ) { 56 | $hostPort = explode(':', DB_HOST); 57 | $dbCredentials['host'] = $hostPort[0]; 58 | $dbCredentials['port'] = $hostPort[1] ?? 3306; 59 | $dbCredentials['name'] = DB_NAME; 60 | $dbCredentials['user'] = DB_USER; 61 | $dbCredentials['pass'] = DB_PASSWORD; 62 | $dbCredentials['charset'] = DB_CHARSET; 63 | $phinxConfig['environments'][$defaultEnvironment] = $dbCredentials; 64 | $this->logger->info('Updating the db credentials for Phinx'); 65 | } 66 | 67 | if (WP_ENV === 'testing' && (defined('USE_MYSQL') && USE_MYSQL)) { 68 | $phinxConfig['environments']['testing']['adapter'] = 'mysql'; 69 | } 70 | 71 | // add db_prefix 72 | $dbPrefix = $wpdb->prefix ?: 'wp_'; 73 | foreach (array_keys($phinxConfig['environments']) as $key) { 74 | if (!str_starts_with($key, 'default_')) { 75 | $phinxConfig['environments'][$key]['table_prefix'] = $dbPrefix; 76 | } 77 | } 78 | 79 | $this->logger->info('Adding db prefix ' . $dbPrefix); 80 | // save to phinx.yml if things have changed 81 | if ($phinxConfig !== $originalConfig) { 82 | $yaml = Yaml::dump($phinxConfig); 83 | file_put_contents($configLocation, $yaml); 84 | $this->logger->info('phinx.yml saved after env changes'); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Database/Migrations.php: -------------------------------------------------------------------------------- 1 | phinxConfigLocation = FORME_PRIVATE_ROOT . 'phinx.yml'; 31 | $this->phinxApplication = new TextWrapper($phinxApplication, ['configuration' => $this->phinxConfigLocation]); 32 | } 33 | 34 | public function migrate(): array 35 | { 36 | $messages = []; 37 | // this should run on plugin/theme install or on upgrade 38 | // create phinx.yml if it doesn't exist yet 39 | $this->initialiser->maybeCreate($this->phinxConfigLocation); 40 | // set the db environments 41 | $this->initialiser->setEnvironments($this->phinxConfigLocation); 42 | // grab the current config and save before changes to check for diff 43 | $phinxConfig = Yaml::parse(file_get_contents($this->phinxConfigLocation)); 44 | $originalConfig = $phinxConfig; 45 | // make sure this framework directory _and_ the plugin/theme migration directories are in 46 | // adding plugins/themes at different points should be ok because older migrations will still run 47 | $migrationPaths = $phinxConfig['paths']['migrations']; 48 | if (!is_array($migrationPaths)) { 49 | // assume this has just been created, so let's blank it 50 | $migrationPaths = []; 51 | $this->logger->info('Initialising migration paths'); 52 | } 53 | 54 | // add plugin/theme paths if not in 55 | if (self::isPlugin() && !in_array(self::getPluginPath() . self::DB_DIR, $migrationPaths)) { 56 | $migrationPaths[] = self::getPluginPath() . self::DB_DIR; 57 | $this->logger->info('Adding plugin migration path'); 58 | } elseif (!self::isPlugin() && !in_array(self::getThemePath() . self::DB_DIR, $migrationPaths)) { 59 | $migrationPaths[] = self::getThemePath() . self::DB_DIR; 60 | $this->logger->info('Adding theme migration path'); 61 | } 62 | 63 | // add framework path if not in 64 | if (!in_array(__DIR__ . '/Migrations', $migrationPaths)) { 65 | $migrationPaths[] = __DIR__ . '/Migrations'; 66 | $this->logger->info('Adding framework migration path'); 67 | } 68 | 69 | $phinxConfig['paths']['migrations'] = $migrationPaths; 70 | // save to phinx.yml if things have changed 71 | if ($phinxConfig !== $originalConfig) { 72 | $yaml = Yaml::dump($phinxConfig); 73 | file_put_contents($this->phinxConfigLocation, $yaml); 74 | $messages[] = ['type' => 'success', 'text' => 'Forme phinx.yml saved, check ' . $this->phinxConfigLocation]; 75 | $this->logger->info('phinx.yml saved after path changes'); 76 | } 77 | 78 | $this->logger->info('Migration path config completed'); 79 | // run migrations 80 | $output = $this->phinxApplication->getMigrate(WP_ENV ?: 'development'); 81 | $this->logger->info($output); 82 | $messages[] = ['type' => 'success', 'text' => 'Forme Migration completed, check server logs for more info']; 83 | 84 | return $messages; 85 | } 86 | 87 | /** 88 | * CAREFUL!!! This method rolls back all forme migrations and thereby borks your database state. You probably only want this in a testing environment. 89 | */ 90 | public function rollback(): array 91 | { 92 | $messages = []; 93 | // run migrations 94 | $output = $this->phinxApplication->getRollback(WP_ENV ?: 'testing', 0); 95 | $this->logger->info($output); 96 | $messages[] = ['type' => 'success', 'text' => 'Forme Rollback completed, check server logs for more info']; 97 | 98 | return $messages; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Database/Migrations/20201018145438_create_auth_tokens_table.php: -------------------------------------------------------------------------------- 1 | table('forme_auth_tokens'); 23 | $table->addColumn('name', 'string') 24 | ->addColumn('token', 'string') 25 | ->addColumn('expiry', 'datetime') 26 | ->create(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Database/Migrations/20210112174503_create_queued_jobs_table.php: -------------------------------------------------------------------------------- 1 | table('forme_queued_jobs'); 28 | $table->addColumn('class', 'string') 29 | ->addColumn('arguments', 'text') 30 | ->addColumn('started_at', 'datetime', ['null' => true]) 31 | ->addColumn('completed_at', 'datetime', ['null' => true]) 32 | ->addColumn('created_at', 'datetime') 33 | ->addColumn('updated_at', 'datetime') 34 | ->create(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Database/Migrations/20210122110424_update_queued_jobs_table.php: -------------------------------------------------------------------------------- 1 | table('forme_queued_jobs'); 27 | $table 28 | ->addColumn('scheduled_for', 'datetime') 29 | ->addColumn('frequency', 'string', ['null' => true]) 30 | ->update(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Database/Migrations/20221203173957_add_queue_name_to_queued_jobs_table.php: -------------------------------------------------------------------------------- 1 | table('forme_queued_jobs'); 27 | $table 28 | ->addColumn('queue_name', 'string', ['null' => true]) 29 | ->update(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Database/Migrations/20230320094337_create_events_table.php: -------------------------------------------------------------------------------- 1 | table('forme_events', ['signed' => false]); 18 | $table->addColumn('type', 'string') 19 | ->addColumn('payload', 'text', ['limit' => MysqlAdapter::TEXT_LONG]) 20 | ->addColumn('created_at', 'datetime') 21 | ->create(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Database/Migrations/20230614065259_add_soft_delete_to_auth_table.php: -------------------------------------------------------------------------------- 1 | table('forme_auth_tokens'); 11 | $table->addColumn('deleted_at', 'datetime', ['null' => true]); 12 | $table->save(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Database/phinx.default.yml: -------------------------------------------------------------------------------- 1 | paths: 2 | migrations: "%%PHINX_CONFIG_DIR%%/db/migrations" 3 | seeds: "%%PHINX_CONFIG_DIR%%/db/seeds" 4 | environments: 5 | default_migration_table: phinxlog 6 | default_environment: development 7 | production: 8 | { 9 | adapter: mysql, 10 | host: localhost, 11 | name: production_db, 12 | user: root, 13 | pass: "", 14 | port: 3306, 15 | charset: utf8, 16 | } 17 | development: 18 | { 19 | adapter: mysql, 20 | host: localhost, 21 | name: development_db, 22 | user: root, 23 | pass: "", 24 | port: 3306, 25 | charset: utf8, 26 | } 27 | testing: 28 | { 29 | adapter: sqlite, 30 | host: localhost, 31 | name: testing_db, 32 | user: root, 33 | pass: "", 34 | port: 3306, 35 | charset: utf8, 36 | } 37 | version_order: creation 38 | -------------------------------------------------------------------------------- /src/Enums/AcfEnum.php: -------------------------------------------------------------------------------- 1 | fieldNames[$this->value]; 21 | } 22 | 23 | /** 24 | * @return int|string 25 | * 26 | * @throws UnknownEnumProperty 27 | */ 28 | public function __get(string $name) 29 | { 30 | return match ($name) { 31 | 'name' => $this->acfName(), 32 | 'key' => $this->value, 33 | 'value' => $this->value, 34 | 'label' => $this->label, 35 | default => throw new UnknownEnumProperty($name), 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Fields/FieldGroupInterface.php: -------------------------------------------------------------------------------- 1 | callbacks, function ($filter) use ($callable) { 17 | $filter = array_values($filter); 18 | $filter = $filter[0]; 19 | if (is_array($filter['function']) && isset($filter['function'][0])) { 20 | return str_contains($filter['function'][0]::class, $callable); 21 | } elseif (is_string($filter['function'])) { 22 | return str_contains($filter['function'], $callable); 23 | } elseif ($filter['function'] instanceof $callable || $filter['function'] == $callable) { 24 | return true; 25 | } 26 | }); 27 | if ($alreadySet !== []) { 28 | return true; 29 | } 30 | } 31 | 32 | return false; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Hooks/JobCommandHook.php: -------------------------------------------------------------------------------- 1 | input()[$offset]); 11 | } 12 | 13 | public function offsetGet(mixed $offset): mixed 14 | { 15 | return $this->input()[$offset] ?? null; 16 | } 17 | 18 | public function offsetSet(mixed $offset, mixed $value): void 19 | { 20 | if (is_null($offset)) { 21 | $this->input()[] = $value; 22 | } else { 23 | $this->input()[$offset] = $value; 24 | } 25 | } 26 | 27 | public function offsetUnset(mixed $offset): void 28 | { 29 | unset($this->input()[$offset]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/Handlers/AjaxHandler.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 23 | } 24 | 25 | public function __invoke(): void 26 | { 27 | $request = \Forme\request(); 28 | 29 | $controller = $this->handler; 30 | 31 | // we pass the response closure into the dispatcher 32 | $responseFunc = function ($request, $handler) use ($controller) { 33 | $response = call_user_func($controller, $request); 34 | 35 | return ResponseFactory::create($response); 36 | }; 37 | $response = $this->dispatchMiddleware($request, $responseFunc); 38 | $this->shutdown->shutdown($response); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Http/Handlers/CustomRouteHandler.php: -------------------------------------------------------------------------------- 1 | router = $factory->make(AltoRouter::class); 33 | } 34 | 35 | public function __invoke(): void 36 | { 37 | if (!$this->matched) { 38 | $route = $this->router->match(); 39 | if ($route && isset($route['target'])) { 40 | $this->matched = true; 41 | // We call this here because things like admin bar rely on this hook 42 | // but TODO: test for side-effects? 43 | do_action('template_redirect'); 44 | $request = \Forme\request(); 45 | if (isset($route['params'])) { 46 | $request = $request->withQueryParams($route['params'] + $request->getQueryParams()); 47 | } 48 | 49 | // we pass the response closure into the dispatcher 50 | $responseFunc = function ($request, $handler) use ($route) { 51 | $response = call_user_func($route['target'], $request); 52 | 53 | return ResponseFactory::create($response); 54 | }; 55 | $response = $this->dispatchMiddleware($request, $responseFunc); 56 | $this->shutdown->shutdown($response); 57 | } 58 | } 59 | } 60 | 61 | /** 62 | * @param string $route A string to match (ex: 'myfoo') 63 | * @param callable $callback A function to run, examples: 64 | * Routes::map('myfoo', 'my_callback_function'); 65 | * Routes::map('mybaq', array($my_class, 'method')); 66 | * Routes::map('myqux', function() { 67 | * //stuff goes here 68 | * }); 69 | */ 70 | public function map(string $route, callable $callback, string $method = 'GET', string $args = ''): void 71 | { 72 | $route = $this->convertRoute($route); 73 | $this->router->map($method, trailingslashit($route), $callback, $args); 74 | $this->router->map($method, untrailingslashit($route), $callback, $args); 75 | } 76 | 77 | /** 78 | * @return string A string in a format for AltoRouter 79 | * ex: [:my_param] 80 | */ 81 | private function convertRoute(string $routeString): string 82 | { 83 | if (strpos($routeString, '[') > -1) { 84 | return $routeString; 85 | } 86 | 87 | $routeString = preg_replace('#(:)\w+#', '/[$0]', $routeString); 88 | $routeString = str_replace('[[', '[', $routeString); 89 | $routeString = str_replace(']]', ']', $routeString); 90 | $routeString = str_replace('[/:', '[:', $routeString); 91 | $routeString = str_replace('//[', '/[', $routeString); 92 | if (str_starts_with($routeString, '/')) { 93 | $routeString = substr($routeString, 1); 94 | } 95 | 96 | return $routeString; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Http/Handlers/HandlerInterface.php: -------------------------------------------------------------------------------- 1 | middlewareQueue[] = $middleware; 19 | } 20 | 21 | public function getMiddlewareQueue(): array 22 | { 23 | return $this->middlewareQueue; 24 | } 25 | 26 | public function addMiddlewareQueue(array $queue): void 27 | { 28 | $this->middlewareQueue = array_merge($this->middlewareQueue, $queue); 29 | } 30 | 31 | public function dispatchMiddleware(ServerRequestInterface $request, callable $responseFunc): ResponseInterface 32 | { 33 | $queue = array_merge($this->middlewareQueue ?: [], [$responseFunc]); 34 | $resolver = fn ($entry) => is_string($entry) ? \Forme\getInstance($entry) : $entry; 35 | $relay = new Relay($queue, $resolver); 36 | 37 | return $relay->handle($request); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Http/Handlers/RestHandler.php: -------------------------------------------------------------------------------- 1 | handler = $handler; 26 | } 27 | 28 | public function __invoke(WP_REST_Request $request): ResponseInterface 29 | { 30 | // convert from WP_REST into PSR7 31 | $request = ServerRequest::fromRequest($request); 32 | 33 | $restHandler = $this->handler; 34 | 35 | // we pass the response closure into the dispatcher 36 | $responseFunc = function ($request, $handler) use ($restHandler) { 37 | $response = call_user_func($restHandler, $request); 38 | 39 | return ResponseFactory::create($response); 40 | }; 41 | $response = $this->dispatchMiddleware($request, $responseFunc); 42 | 43 | $this->shutdown->shutdown($response); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Http/Handlers/TemplateHandler.php: -------------------------------------------------------------------------------- 1 | getControllerClassFromTemplate($template); 33 | 34 | return $this->handleRequest($controller); 35 | } 36 | 37 | private function getControllerClassFromTemplate(string $template): string 38 | { 39 | $controllerName = (new UnicodeString(basename($template, '.php')))->camel()->title()->toString(); 40 | // if the name doesn't end with Controller then add 'Controller' 41 | $controllerName .= str_ends_with($controllerName, 'Controller') ? '' : 'Controller'; 42 | // Classes can't start with a number so we have to special case the behaviour here 43 | if ($controllerName === '404Controller') { 44 | $controllerName = 'Error' . $controllerName; 45 | } 46 | 47 | return $this->getNameSpace($template) . '\\' . $controllerName; 48 | } 49 | 50 | /** @return null */ 51 | private function handleRequest(string $controllerName) 52 | { 53 | if (!class_exists($controllerName)) { 54 | return null; 55 | } 56 | 57 | /** @var ControllerInterface */ 58 | $controller = $this->container->get($controllerName); 59 | $this->addMiddlewareQueue($controller->getMiddlewareQueue()); 60 | 61 | $postId = get_queried_object_id(); 62 | 63 | // acf sugar 64 | if (function_exists('acf')) { 65 | $fields = get_fields($postId); 66 | $options = get_fields('options'); 67 | } 68 | 69 | $request = \Forme\request(); 70 | $request = $request->withParsedBody([ 71 | 'postId' => $postId, 72 | 'fields' => $fields ? arrayKeysToCamelCase($fields) : [], 73 | 'options' => $options ? arrayKeysToCamelCase($options) : [], 74 | ]); 75 | 76 | // we pass the response closure into the dispatcher 77 | $responseFunc = function ($request, $handler) use ($controller) { 78 | $response = $controller->handle($request); 79 | 80 | return ResponseFactory::create($response); 81 | }; 82 | $response = $this->dispatchMiddleware($request, $responseFunc); 83 | $this->shutdown->shutdown($response); 84 | } 85 | 86 | /** 87 | * Extract the namespace from file contents. 88 | */ 89 | private function getNameSpace(string $file): ?string 90 | { 91 | $fileContents = file_get_contents($file); 92 | if (preg_match('#^namespace\s+(.+?);$#sm', $fileContents, $m)) { 93 | return $m[1]; 94 | } 95 | 96 | return null; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Http/ResponseFactory.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ServerRequest extends DiactorosServerRequest implements ArrayAccess 16 | { 17 | use InteractsWithInput; 18 | use InteractsWithUri; 19 | use ArraysRequestInput; 20 | 21 | public static function fromRequest(ServerRequestInterface $request): ServerRequestInterface 22 | { 23 | $parsedBody = $request->getParsedBody(); 24 | 25 | // Is this a JSON request? 26 | if (stripos($request->getHeaderLine('Content-Type'), 'application/json') !== false) { 27 | $parsedBody = @json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); 28 | } 29 | 30 | return new static( 31 | $request->getServerParams(), 32 | $request->getUploadedFiles(), 33 | $request->getUri(), 34 | $request->getMethod(), 35 | $request->getBody(), 36 | $request->getHeaders(), 37 | $request->getCookieParams(), 38 | $request->getQueryParams(), 39 | $parsedBody, 40 | $request->getProtocolVersion() 41 | ); 42 | } 43 | 44 | public function ajax(): bool 45 | { 46 | if (!$this->hasHeader('X-Requested-With')) { 47 | return false; 48 | } 49 | 50 | return $this->getHeader('X-Requested-With')[0] === 'XMLHttpRequest'; 51 | } 52 | 53 | public function getMethod(): string 54 | { 55 | return strtoupper(parent::getMethod()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Http/Shutdown.php: -------------------------------------------------------------------------------- 1 | send_headers(); 22 | 23 | // If we're handling a WordPressController response at this point then WordPress will already have 24 | // sent headers as it happens earlier in the lifecycle. For this scenario we need to do a bit more 25 | // work to make sure that duplicate headers are not sent back. 26 | send($this->removeSentHeadersAndMoveIntoResponse($response)); 27 | } 28 | 29 | exit(); 30 | } 31 | 32 | private function removeSentHeadersAndMoveIntoResponse(ResponseInterface $response): ResponseInterface 33 | { 34 | // 1. Format the previously sent headers into an array of [key, value] 35 | // 2. Remove all headers from the output that we find 36 | // 3. Filter out any headers that would clash with those already in the response 37 | $headersToAdd = collect(headers_list())->map(function ($header) { 38 | $parts = explode(':', $header, 2); 39 | header_remove($parts[0]); 40 | 41 | return $parts; 42 | })->filter(fn ($header) => strtolower($header[0]) != 'content-type'); 43 | 44 | // Add the previously sent headers into the response 45 | // Note: You can't mutate a response so we need to use the reduce to end up with a response 46 | // object with all the correct headers 47 | /** @var ResponseInterface */ 48 | $responseToSend = collect($headersToAdd)->reduce(fn (ResponseInterface $newResponse, array $header) => $newResponse->withAddedHeader($header[0], $header[1]), $response); 49 | 50 | return $responseToSend; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Http/WPRest/ServerRequest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class ServerRequest extends WPRestServerRequest implements ServerRequestInterface, ArrayAccess 16 | { 17 | use InteractsWithInput; 18 | use InteractsWithUri; 19 | use ArraysRequestInput; 20 | } 21 | -------------------------------------------------------------------------------- /src/Jobs/JobInterface.php: -------------------------------------------------------------------------------- 1 | getQueue(); 15 | $queue->dispatch([ 16 | 'class' => $this::class, 17 | 'arguments' => $args, 18 | 'queue_name' => $queueName, 19 | ]); 20 | } 21 | 22 | public function schedule(array $args = []): void 23 | { 24 | $queue = $this->getQueue(); 25 | $args['class'] = $this::class; 26 | $queue->schedule($args); 27 | } 28 | 29 | public function start(array $args = []): void 30 | { 31 | $queue = $this->getQueue(); 32 | $args['class'] = $this::class; 33 | $queue->start($args); 34 | } 35 | 36 | public function stop(array $args = [], ?string $queueName = null): void 37 | { 38 | $queue = $this->getQueue(); 39 | $queue->stop(['class' => $this::class, 'queue_name' => $queueName, 'arguments' => $args]); 40 | } 41 | 42 | protected function getQueue(): Queue 43 | { 44 | if (property_exists($this, 'queue') && is_a($this->queue, Queue::class)) { 45 | return $this->queue; 46 | } 47 | 48 | if (property_exists($this, 'container') && is_a($this->container, ContainerInterface::class)) { 49 | return $this->container->get(Queue::class); 50 | } 51 | 52 | if (function_exists('app')) { 53 | $reflection = new ReflectionFunction('app'); 54 | $returnType = $reflection->getReturnType(); 55 | if ($returnType && is_a((string) $returnType, ContainerInterface::class, true)) { 56 | return app()->get(Queue::class); 57 | } 58 | } 59 | 60 | if (function_exists('Forme\getInstance') && defined('FORME_PRIVATE_ROOT')) { 61 | return \Forme\getInstance(Queue::class); 62 | } 63 | 64 | throw new RuntimeException('Unable to instantiate queue, either provide a suitable container or inject the queue directly into the job class constructor as $queue'); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Log/LogEventHandler.php: -------------------------------------------------------------------------------- 1 | 'log', 'payload' => $record]); 17 | } catch (Exception $e) { 18 | // noop - table might not exist, or mysql connection might be unavailable, but we don't really want to handle that here 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Log/LogHandlerType.php: -------------------------------------------------------------------------------- 1 | 'Not Allowed outside of test environment'], 401); 20 | } else { 21 | return $handler->handle($request); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Models/Comment.php: -------------------------------------------------------------------------------- 1 | hasOne(\Forme\Framework\Models\Post::class); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Models/CustomPostable.php: -------------------------------------------------------------------------------- 1 | snake()->toString(); 30 | } 31 | 32 | static::addGlobalScope('postType', fn (Builder $query) => $query->where('post_type', '=', $postType)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Models/Event.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | protected $casts = [ 37 | 'payload' => 'array', 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /src/Models/MenuItem.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public function childItems(): Collection 25 | { 26 | $type = $this->menuType; 27 | 28 | $collection = self::with('meta')->whereHas('meta', function (Builder $query) { 29 | $query->where('meta_key', '_menu_item_menu_item_parent')->where('meta_value', (string) $this->ID); 30 | }) 31 | ->orderBy('menu_order') 32 | ->get(); 33 | 34 | return self::attachWPMenuItemData($collection, $type); 35 | } 36 | 37 | public function getUrlAttribute(): string 38 | { 39 | return $this->wordPressMenuItem->url; 40 | } 41 | 42 | /** 43 | * @return ?Collection 44 | */ 45 | public static function getByMenuType(string $type): ?Collection 46 | { 47 | $indexedItems = self::getIndexedWPNavMenuItems($type); 48 | 49 | // bail if null 50 | if (!$indexedItems) { 51 | return null; 52 | } 53 | 54 | // convert to an array of ids 55 | $itemIds = array_keys($indexedItems); 56 | 57 | $collection = self::with('meta')->whereHas('meta', function (Builder $query) { 58 | $query->where('meta_key', '_menu_item_menu_item_parent')->where('meta_value', '0'); 59 | }) 60 | ->whereIn('ID', $itemIds) 61 | ->orderBy('menu_order') 62 | ->get(); 63 | 64 | return self::attachWPMenuItemData($collection, $type); 65 | } 66 | 67 | /** 68 | * @param Collection $collection 69 | * 70 | * @return Collection 71 | */ 72 | private static function attachWPMenuItemData(Collection $collection, string $type): ?Collection 73 | { 74 | $indexedItems = self::getIndexedWPNavMenuItems($type); 75 | 76 | // bail if null 77 | if (!$indexedItems) { 78 | return null; 79 | } 80 | 81 | return $collection->map(function (MenuItem $item) use ($indexedItems, $type) { 82 | $item->menuType = $type; 83 | /** @var WPMenuItem|WP_Post */ 84 | $WPMenuItem = $indexedItems[$item->ID]; 85 | $item->title = $WPMenuItem->title; 86 | $item->url = $WPMenuItem->url; 87 | $item->classes = $WPMenuItem->classes; 88 | $item->target = $WPMenuItem->target; 89 | $item->description = $WPMenuItem->description; 90 | $item->wordPressMenuItem = $WPMenuItem; 91 | 92 | return $item; 93 | }); 94 | } 95 | 96 | /** 97 | * @return array 98 | */ 99 | private static function getIndexedWPNavMenuItems(string $type): ?array 100 | { 101 | // Get the nav menu based on the requested menu. 102 | $menu = wp_get_nav_menu_object($type); 103 | 104 | // Get the nav menu based on the theme_location. 105 | $locations = get_nav_menu_locations(); 106 | if (!$menu && isset($locations[$type])) { 107 | $menu = wp_get_nav_menu_object($locations[$type]); 108 | } 109 | 110 | /* 111 | * If no menu was found: bail. 112 | */ 113 | if (!$menu) { 114 | return null; 115 | } 116 | 117 | // If the menu exists, get its items. 118 | $items = wp_get_nav_menu_items($menu->term_id, ['update_post_term_cache' => false]) ?: []; 119 | 120 | $indexedItems = []; 121 | foreach ($items as $item) { 122 | $indexedItems[$item->ID] = $item; 123 | } 124 | 125 | array_walk($items, function ($item) use ($indexedItems) { 126 | $indexedItems[$item->ID] = $item; 127 | }); 128 | 129 | return $indexedItems; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Models/Option.php: -------------------------------------------------------------------------------- 1 | option_id; 25 | } 26 | 27 | public function getNameAttribute(): string 28 | { 29 | return $this->option_name; 30 | } 31 | 32 | public function setNameAttribute(string $value): void 33 | { 34 | $this->option_name = $value; 35 | } 36 | 37 | public function setValueAttribute(string $value): void 38 | { 39 | $this->option_value = $value; 40 | } 41 | 42 | public function getValueAttribute(): string 43 | { 44 | return $this->option_value; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Models/Post.php: -------------------------------------------------------------------------------- 1 | $children 26 | */ 27 | class Post extends Model 28 | { 29 | use PostSugar; 30 | 31 | protected $table = 'posts'; 32 | 33 | protected $primaryKey = 'ID'; 34 | 35 | /** 36 | * @var string 37 | */ 38 | public const CREATED_AT = 'post_date'; 39 | 40 | /** 41 | * @var string 42 | */ 43 | public const UPDATED_AT = 'post_modified'; 44 | 45 | /** 46 | * Filter by post type. 47 | */ 48 | public function scopeType(Builder $query, string $type = 'post'): Builder 49 | { 50 | return $query->where('post_type', '=', $type); 51 | } 52 | 53 | /** 54 | * Filter by post status. 55 | */ 56 | public function scopeStatus(Builder $query, string $status = 'publish'): Builder 57 | { 58 | return $query->where('post_status', '=', $status); 59 | } 60 | 61 | /** 62 | * Filter by post author. 63 | */ 64 | public function scopeAuthor(Builder $query, ?string $author = null): ?Builder 65 | { 66 | if ($author) { 67 | return $query->where('post_author', '=', $author); 68 | } else { 69 | return null; 70 | } 71 | } 72 | 73 | /** 74 | * Get comments from the post. 75 | */ 76 | public function comments(): HasMany 77 | { 78 | return $this->hasMany(Comment::class, 'comment_post_ID'); 79 | } 80 | 81 | /** 82 | * Get meta fields from the post. 83 | */ 84 | public function meta(): HasMany 85 | { 86 | return $this->hasMany(PostMeta::class, 'post_id'); 87 | } 88 | 89 | /** 90 | * @param string $key 91 | * 92 | * @return mixed 93 | */ 94 | public function __get($key) 95 | { 96 | // if key ends with "_meta" return the meta value 97 | if (str_ends_with($key, '_meta')) { 98 | return $this->meta->firstWhere('meta_key', substr($key, 0, -5))?->meta_value; 99 | } 100 | 101 | return parent::__get($key); 102 | } 103 | 104 | public function generateSlug(): void 105 | { 106 | $slug = wp_unique_post_slug( 107 | sanitize_title($this->post_title), 108 | $this->ID, 109 | $this->post_status, 110 | $this->post_type, 111 | $this->post_parent 112 | ); 113 | 114 | $this->post_name = $slug; 115 | } 116 | 117 | public function save(array $options = []) 118 | { 119 | if (!$this->post_name) { 120 | $this->generateSlug(); 121 | } 122 | 123 | return parent::save($options); 124 | } 125 | 126 | /** 127 | * @return Collection 128 | */ 129 | public function getChildrenAttribute(): Collection 130 | { 131 | return self::where('post_parent', $this->ID) 132 | ->where('post_status', 'publish') 133 | ->get(); 134 | } 135 | 136 | public function hasChildren(): bool 137 | { 138 | return $this->children->isNotEmpty(); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Models/PostMeta.php: -------------------------------------------------------------------------------- 1 | belongsTo(Post::class, 'post_id', 'ID'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Models/PostSugar.php: -------------------------------------------------------------------------------- 1 | ID); 25 | } 26 | 27 | public function getTitleAttribute(): string 28 | { 29 | return $this->post_title; 30 | } 31 | 32 | public function setTitleAttribute(string $value): void 33 | { 34 | $this->post_title = $value; 35 | } 36 | 37 | public function getContentAttribute(): string 38 | { 39 | return $this->post_content; 40 | } 41 | 42 | public function setContentAttribute(string $value): void 43 | { 44 | $this->post_content = $value; 45 | } 46 | 47 | public function getExcerptAttribute(): string 48 | { 49 | return $this->post_excerpt; 50 | } 51 | 52 | public function setExcerptAttribute(string $value): void 53 | { 54 | $this->post_excerpt = $value; 55 | } 56 | 57 | public function getSlugAttribute(): string 58 | { 59 | return $this->post_name; 60 | } 61 | 62 | public function setSlugAttribute(string $value): void 63 | { 64 | $this->post_name = $value; 65 | } 66 | 67 | public function getTypeAttribute(): string 68 | { 69 | return $this->post_type; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Models/PostTypeInterface.php: -------------------------------------------------------------------------------- 1 | 'datetime', 14 | 'started_at' => 'datetime', 15 | 'completed_at' => 'datetime', 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /src/Models/User.php: -------------------------------------------------------------------------------- 1 | hasMany(UserMeta::class, 'user_id'); 25 | } 26 | 27 | /** 28 | * @param string $key 29 | * 30 | * @return mixed 31 | */ 32 | public function __get($key) 33 | { 34 | // if key ends with "_meta" return the meta value 35 | if (str_ends_with($key, '_meta')) { 36 | return $this->meta->firstWhere('meta_key', substr($key, 0, -5))?->meta_value; 37 | } 38 | 39 | return parent::__get($key); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Models/UserMeta.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class, 'user_id', 'ID'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Models/UserSugar.php: -------------------------------------------------------------------------------- 1 | user_url; 27 | } 28 | 29 | public function setUrlAttribute(string $value): void 30 | { 31 | $this->user_url = $value; 32 | } 33 | 34 | public function getLoginAttribute(): string 35 | { 36 | return $this->user_login; 37 | } 38 | 39 | public function setLoginAttribute(string $value): void 40 | { 41 | $this->user_login = $value; 42 | } 43 | 44 | public function getNicenameAttribute(): string 45 | { 46 | return $this->user_nicename; 47 | } 48 | 49 | public function setNicenameAttribute(string $value): void 50 | { 51 | $this->user_nicename = $value; 52 | } 53 | 54 | public function getEmailAttribute(): string 55 | { 56 | return $this->user_email; 57 | } 58 | 59 | public function setEmailAttribute(string $value): void 60 | { 61 | $this->user_email = $value; 62 | } 63 | 64 | public function getActivationKeyAttribute(): string 65 | { 66 | return $this->user_activation_key; 67 | } 68 | 69 | public function setActivationKeyAttribute(string $value): void 70 | { 71 | $this->user_activation_key = $value; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Models/WPMenuItem.php: -------------------------------------------------------------------------------- 1 | container->make(AjaxHandler::class); 25 | $ajaxHandler->setHandler($handler); 26 | add_action('wp_ajax_' . $route, $ajaxHandler); 27 | 28 | return $ajaxHandler; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Router/Strategy/AjaxPublicStrategy.php: -------------------------------------------------------------------------------- 1 | container->make(AjaxHandler::class); 25 | $ajaxHandler->setHandler($handler); 26 | 27 | add_action('wp_ajax_nopriv_' . $route, $ajaxHandler); 28 | 29 | return $ajaxHandler; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Router/Strategy/CustomStrategy.php: -------------------------------------------------------------------------------- 1 | container->make(CustomRouteHandler::class); 25 | $customRouteHandler->map($route, $handler, $method ?? 'GET'); 26 | add_action('wp_loaded', $customRouteHandler); 27 | 28 | return $customRouteHandler; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Router/Strategy/RestStrategy.php: -------------------------------------------------------------------------------- 1 | container->make(RestHandler::class); 25 | $restHandler->setHandler($handler); 26 | add_action('rest_api_init', function () use ($method, $restHandler, $route) { 27 | $routeBase = substr($route, strpos($route, '/', 1)); 28 | $namespace = str_replace($routeBase, '', $route); 29 | register_rest_route($namespace, $routeBase, [ 30 | 'methods' => $method ?? 'GET', 31 | 'callback' => $restHandler, 32 | 'permission_callback' => '__return_true', 33 | ]); 34 | }); 35 | 36 | return $restHandler; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Router/Strategy/StrategyException.php: -------------------------------------------------------------------------------- 1 | camel()->title()->toString() . 'Strategy'; 24 | 25 | return $this->container->get($class); 26 | } else { 27 | throw new StrategyException('Undefined routing type'); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Router/Strategy/StrategyInterface.php: -------------------------------------------------------------------------------- 1 | container = $container ?: new Container(); 35 | 36 | $this->setupContainer((array) $viewPaths, $cachePath); 37 | (new ViewServiceProvider($this->container))->register(); 38 | 39 | $this->factory = $this->container->get('view'); 40 | $this->compiler = $this->container->get('blade.compiler'); 41 | } 42 | 43 | public function render(string $view, array $data = [], array $mergeData = []): string 44 | { 45 | return $this->make($view, $data, $mergeData)->render(); 46 | } 47 | 48 | public function make($view, $data = [], $mergeData = []): View 49 | { 50 | return $this->factory->make($view, $data, $mergeData); 51 | } 52 | 53 | public function compiler(): BladeCompiler 54 | { 55 | return $this->compiler; 56 | } 57 | 58 | public function directive(string $name, callable $handler): void 59 | { 60 | $this->compiler->directive($name, $handler); 61 | } 62 | 63 | public function if(string $name, callable $callback): void 64 | { 65 | $this->compiler->if($name, $callback); 66 | } 67 | 68 | public function exists($view): bool 69 | { 70 | return $this->factory->exists($view); 71 | } 72 | 73 | public function file($path, $data = [], $mergeData = []): View 74 | { 75 | return $this->factory->file($path, $data, $mergeData); 76 | } 77 | 78 | public function share($key, $value = null) 79 | { 80 | return $this->factory->share($key, $value); 81 | } 82 | 83 | public function composer($views, $callback): array 84 | { 85 | return $this->factory->composer($views, $callback); 86 | } 87 | 88 | public function creator($views, $callback): array 89 | { 90 | return $this->factory->creator($views, $callback); 91 | } 92 | 93 | public function addNamespace($namespace, $hints): self 94 | { 95 | $this->factory->addNamespace($namespace, $hints); 96 | 97 | return $this; 98 | } 99 | 100 | public function replaceNamespace($namespace, $hints): self 101 | { 102 | $this->factory->replaceNamespace($namespace, $hints); 103 | 104 | return $this; 105 | } 106 | 107 | public function __call(string $method, array $params): mixed 108 | { 109 | return call_user_func_array([$this->factory, $method], $params); 110 | } 111 | 112 | protected function setupContainer(array $viewPaths, string $cachePath): void 113 | { 114 | $this->container->bindIf('files', fn () => new Filesystem(), true); 115 | 116 | $this->container->bindIf('events', fn () => new Dispatcher(), true); 117 | 118 | $this->container->bindIf('config', fn () => new Config([ 119 | 'view.paths' => $viewPaths, 120 | 'view.compiled' => $cachePath, 121 | ]), true); 122 | 123 | Facade::setFacadeApplication($this->container); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/View/Blade/Directives.php: -------------------------------------------------------------------------------- 1 | getFileName()); 22 | 23 | if (file_exists($directiveSet = $dir . '/Directives/' . $name . '.php')) { 24 | return require_once $directiveSet; 25 | } 26 | 27 | return null; 28 | } 29 | 30 | public function collect(): Collection 31 | { 32 | return collect($this->directives) 33 | ->flatMap(function ($directive) { 34 | if ($directive === 'ACF' && !function_exists('acf')) { 35 | return null; 36 | } 37 | 38 | if ($directive === 'Local') { 39 | return require_once __DIR__ . '/Local.php'; 40 | } 41 | 42 | return $this->get($directive); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/View/Blade/Local.php: -------------------------------------------------------------------------------- 1 | fn ($name = "''") => '', 6 | 'languageatrributes' => fn ($doctype = "'html'") => '', 7 | ]; 8 | -------------------------------------------------------------------------------- /src/View/BladeView.php: -------------------------------------------------------------------------------- 1 | view = new Blade($this->getDir() . '/../../views', FORME_PRIVATE_ROOT . 'view-cache'); 18 | $directives->collect() 19 | ->each(function ($directive, $function) { 20 | $this->view->directive($function, $directive); 21 | }); 22 | } 23 | 24 | public function render(string $template, array $context = []): string 25 | { 26 | return $this->view->render($template, $context); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/View/GetsDirectory.php: -------------------------------------------------------------------------------- 1 | getFileName(); 12 | 13 | return dirname($filename); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/View/LegacyPlatesView.php: -------------------------------------------------------------------------------- 1 | view = Engine::create($this->getDir() . self::RELATIVE_VIEW_DIR, 'plate.php'); 20 | } 21 | 22 | public function render(string $template, array $context = []): string 23 | { 24 | $template = MagicResolver::resolve($template); 25 | 26 | return $this->view->render($template, $context); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/View/Plates/CaptainHook/Lint.php: -------------------------------------------------------------------------------- 1 | getIndexOperator()->getStagedFilesOfType('php', ['A', 'C', 'M']); 28 | $changedPlateFiles = array_filter($changedPlateFiles, fn ($file) => $this->isPlatesFile($file)); 29 | 30 | $directory = dirname($config->getPath()); 31 | 32 | $failedFiles = []; 33 | $messages = []; 34 | 35 | foreach ($changedPlateFiles as $file) { 36 | $prefix = IOUtil::PREFIX_OK; 37 | if ($this->hasValidationErrors($directory . '/' . $file)) { 38 | $prefix = IOUtil::PREFIX_FAIL; 39 | $failedFiles[] = $directory . '/' . $file; 40 | } 41 | $messages[] = $prefix . ' ' . $file; 42 | } 43 | 44 | $io->write(['', '', implode(PHP_EOL, $messages), ''], true, IO::VERBOSE); 45 | 46 | $failedFilesCount = count($failedFiles); 47 | 48 | if ($failedFilesCount > 0) { 49 | throw new ActionFailed('Linting failed: View template errors in ' . $failedFilesCount . ' file(s)' . PHP_EOL . PHP_EOL . implode(PHP_EOL, $failedFiles)); 50 | } 51 | } 52 | 53 | /** 54 | * Lint a php file. 55 | */ 56 | protected function hasValidationErrors(string $file): bool 57 | { 58 | try { 59 | Validator::validateFile($file); 60 | 61 | return false; 62 | } catch (TemplateErrorException $e) { 63 | return true; 64 | } 65 | } 66 | 67 | protected function isPlatesFile(string $file): bool 68 | { 69 | $filename = pathinfo($file, PATHINFO_FILENAME); 70 | $extension = pathinfo($file, PATHINFO_EXTENSION); 71 | 72 | // handle both .php and .phtml files 73 | if ($extension === 'php' || $extension === 'phtml') { 74 | return str_ends_with($filename, 'plate'); 75 | } 76 | 77 | return false; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/View/Plates/Engine.php: -------------------------------------------------------------------------------- 1 | container = $container ?: Util\Container::create(['engine_methods' => []]); 13 | } 14 | 15 | /** Create a configured engine and set the base dir and extension optionally */ 16 | public static function create($base_dir, $ext = null) 17 | { 18 | return self::createWithConfig(array_filter([ 19 | 'base_dir' => $base_dir, 20 | 'ext' => $ext, 21 | ])); 22 | } 23 | 24 | /** Create a configured engine and pass in an array to configure after extension registration */ 25 | public static function createWithConfig(array $config = []) 26 | { 27 | $plates = new self(); 28 | 29 | $plates->register(new PlatesExtension()); 30 | $plates->register(new Extension\Data\DataExtension()); 31 | $plates->register(new Extension\Path\PathExtension()); 32 | $plates->register(new Extension\RenderContext\RenderContextExtension()); 33 | $plates->register(new Extension\LayoutSections\LayoutSectionsExtension()); 34 | $plates->register(new Extension\Folders\FoldersExtension()); 35 | 36 | $plates->addConfig($config); 37 | 38 | return $plates; 39 | } 40 | 41 | public function render(string $template, array $data = [], array $attributes = []): string 42 | { 43 | $template = MagicResolver::resolve($template); 44 | 45 | return $this->container->get('renderTemplate')->renderTemplate(new Template( 46 | $template, 47 | $data, 48 | $attributes 49 | )); 50 | } 51 | 52 | public function addMethods(array $methods) 53 | { 54 | $this->container->merge('engine_methods', $methods); 55 | } 56 | 57 | public function __call($method, array $args) 58 | { 59 | $methods = $this->container->get('engine_methods'); 60 | if (isset($methods[$method])) { 61 | return $methods[$method]($this, ...$args); 62 | } 63 | 64 | throw new \BadMethodCallException(sprintf('No method %s found for engine.', $method)); 65 | } 66 | 67 | public function register(Extension $extension) 68 | { 69 | $extension->register($this); 70 | } 71 | 72 | public function getContainer() 73 | { 74 | return $this->container; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/View/Plates/Exception/FuncException.php: -------------------------------------------------------------------------------- 1 | getContainer(); 13 | $c->add('data.globals', []); 14 | $c->add('data.template_data', []); 15 | 16 | $plates->defineConfig(['merge_parent_data' => true]); 17 | $plates->pushComposers(fn ($c) => array_filter([ 18 | 'data.addGlobals' => $c->get('data.globals') ? addGlobalsCompose($c->get('data.globals')) : null, 19 | 'data.mergeParentData' => $c->get('config')['merge_parent_data'] ? mergeParentDataCompose() : null, 20 | 'data.perTemplateData' => $c->get('data.template_data') ? perTemplateDataCompose($c->get('data.template_data')) : null, 21 | ])); 22 | 23 | $plates->addMethods([ 24 | 'addGlobals' => function (Plates\Engine $e, array $data) { 25 | $c = $e->getContainer(); 26 | $c->merge('data.globals', $data); 27 | }, 28 | 'addGlobal' => function (Plates\Engine $e, $name, $value) { 29 | $e->getContainer()->merge('data.globals', [$name => $value]); 30 | }, 31 | 'addData' => function (Plates\Engine $e, $data, $name = null) { 32 | if (!$name) { 33 | return $e->addGlobals($data); 34 | } 35 | 36 | $template_data = $e->getContainer()->get('data.template_data'); 37 | if (!isset($template_data[$name])) { 38 | $template_data[$name] = []; 39 | } 40 | 41 | $template_data[$name] = array_merge($template_data[$name], $data); 42 | $e->getContainer()->add('data.template_data', $template_data); 43 | }, 44 | ]); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/Data/data.php: -------------------------------------------------------------------------------- 1 | $template->withData(array_merge($globals, $template->data)); 10 | } 11 | 12 | function mergeParentDataCompose() 13 | { 14 | return fn (Template $template) => $template->parent !== null 15 | ? $template->withData(array_merge($template->parent()->data, $template->data)) 16 | : $template; 17 | } 18 | 19 | function perTemplateDataCompose(array $template_data_map) 20 | { 21 | return function (Template $template) use ($template_data_map) { 22 | $name = $template->get('normalized_name', $template->name); 23 | 24 | return isset($template_data_map[$name]) 25 | ? $template->withData(array_merge($template_data_map[$name], $template->data)) 26 | : $template; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/ExtensionInterface.php: -------------------------------------------------------------------------------- 1 | getContainer(); 12 | $c->add('folders.folders', []); 13 | $c->wrapStack('path.resolvePath', fn ($stack, $c) => array_merge($stack, [ 14 | 'folders' => foldersResolvePath( 15 | $c->get('folders.folders'), 16 | $c->get('config')['folder_separator'], 17 | $c->get('fileExists') 18 | ), 19 | ])); 20 | $c->wrapComposed('path.normalizeName', fn ($composed, $c) => array_merge($composed, [ 21 | 'folders.stripFolders' => stripFoldersNormalizeName($c->get('folders.folders')), 22 | ])); 23 | 24 | $plates->defineConfig([ 25 | 'folder_separator' => '::', 26 | ]); 27 | $plates->addMethods([ 28 | 'addFolder' => function ($plates, $folder, $prefixes, $fallback = false) { 29 | $prefixes = is_string($prefixes) ? [$prefixes] : $prefixes; 30 | if ($fallback) { 31 | $prefixes[] = ''; 32 | } 33 | 34 | $plates->getContainer()->merge('folders.folders', [ 35 | $folder => [ 36 | 'folder' => $folder, 37 | 'prefixes' => $prefixes, 38 | ], 39 | ]); 40 | }, 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/Folders/folders.php: -------------------------------------------------------------------------------- 1 | path, $sep)) { 14 | return $next($args); 15 | } 16 | 17 | [$folder, $name] = explode($sep, $args->path); 18 | if (!isset($folders[$folder])) { 19 | return $next($args); 20 | } 21 | 22 | $folder_struct = $folders[$folder]; 23 | 24 | foreach ($folder_struct['prefixes'] as $prefix) { 25 | $path = $next($args->withPath( 26 | Plates\Util\joinPath([$prefix, $name]) 27 | )); 28 | 29 | // no need to check if file exists if we only have prefix 30 | if ((is_countable($folder_struct['prefixes']) ? count($folder_struct['prefixes']) : 0) == 1 || $file_exists($path)) { 31 | return $path; 32 | } 33 | } 34 | 35 | // none of the paths matched, just return what we have. 36 | return $path; 37 | }; 38 | } 39 | 40 | function stripFoldersNormalizeName(array $folders, string $sep = '::'): Closure 41 | { 42 | return function ($name) use ($folders, $sep) { 43 | foreach ($folders as $folder) { 44 | foreach (array_filter($folder['prefixes']) as $prefix) { 45 | if (str_starts_with($name, $prefix)) { 46 | return $folder['folder'] . $sep . substr($name, strlen($prefix) + 1); 47 | } 48 | } 49 | } 50 | 51 | return $name; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/LayoutSections/DefaultLayoutRenderTemplate.php: -------------------------------------------------------------------------------- 1 | parent || $template->get('no_layout')) { 15 | return $this->render->renderTemplate($template, $rt ?: $this); 16 | } 17 | 18 | $ref = $template->reference; 19 | $contents = $this->render->renderTemplate($template, $rt ?: $this); 20 | 21 | if ($ref()->get('layout')) { 22 | return $contents; 23 | } 24 | 25 | $layout = $ref()->fork($this->layout_path); 26 | $ref()->with('layout', $layout->reference); 27 | 28 | return $contents; 29 | } 30 | 31 | public static function factory($layout_path) { 32 | return fn(Plates\RenderTemplate $rt) => new self($rt, $layout_path); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/LayoutSections/LayoutRenderTemplate.php: -------------------------------------------------------------------------------- 1 | reference; 11 | $content = $this->render->renderTemplate($template, $rt ?: $this); 12 | 13 | $layout_ref = $ref()->get('layout'); 14 | if (!$layout_ref) { 15 | return $content; 16 | } 17 | 18 | $layout = $layout_ref()->with('sections', $ref()->get('sections')); 19 | $layout->get('sections')->add('content', $content); 20 | 21 | return ($rt ?: $this)->renderTemplate($layout); 22 | } 23 | 24 | public static function factory() { 25 | return fn(Plates\RenderTemplate $render) => new self($render); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/LayoutSections/LayoutSectionsExtension.php: -------------------------------------------------------------------------------- 1 | getContainer(); 13 | 14 | $c->wrap('renderTemplate.factories', function ($factories, $c) { 15 | $default_layout_path = $c->get('config')['default_layout_path']; 16 | if ($default_layout_path) { 17 | $factories[] = DefaultLayoutRenderTemplate::factory($default_layout_path); 18 | } 19 | 20 | $factories[] = LayoutRenderTemplate::factory(); 21 | 22 | return $factories; 23 | }); 24 | 25 | $plates->defineConfig(['default_layout_path' => null]); 26 | $plates->pushComposers(fn ($c) => ['layoutSections.sections' => sectionsCompose()]); 27 | $plates->addFuncs(function ($c) { 28 | $template_args = RenderContext\assertTemplateArgsFunc(); 29 | $one_arg = RenderContext\assertArgsFunc(1); 30 | 31 | return [ 32 | 'layout' => [layoutFunc(), $template_args], 33 | 'section' => [sectionFunc(), RenderContext\assertArgsFunc(1, 1)], 34 | 'start' => [startFunc(), $one_arg], 35 | 'push' => [startFunc(START_APPEND), $one_arg], 36 | 'unshift' => [startFunc(START_PREPEND), $one_arg], 37 | ]; 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/LayoutSections/Sections.php: -------------------------------------------------------------------------------- 1 | sections[$name] = $content; 16 | } 17 | 18 | public function append($name, $content) 19 | { 20 | $this->sections[$name] = ($this->get($name) ?: '') . $content; 21 | } 22 | 23 | public function prepend($name, $content) 24 | { 25 | $this->sections[$name] = $content . ($this->get($name) ?: ''); 26 | } 27 | 28 | public function clear($name) 29 | { 30 | unset($this->sections[$name]); 31 | } 32 | 33 | public function get($name) 34 | { 35 | return $this->sections[$name] ?? null; 36 | } 37 | 38 | public function merge(Sections $sections) 39 | { 40 | return new self(array_merge($this->sections, $sections->sections)); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/LayoutSections/layout-sections.php: -------------------------------------------------------------------------------- 1 | $template->with('sections', $template->parent !== null ? $template->parent()->get('sections') : new Sections()); 13 | } 14 | 15 | function layoutFunc() 16 | { 17 | return function (FuncArgs $args) { 18 | [$name, $data] = $args->args; 19 | 20 | $layout = $args->template()->fork($name, $data ?: []); 21 | $args->template()->with('layout', $layout->reference); 22 | 23 | return $layout; 24 | }; 25 | } 26 | 27 | function sectionFunc() 28 | { 29 | return function (FuncArgs $args) { 30 | [$name, $else] = $args->args; 31 | 32 | $res = $args->template()->get('sections')->get($name); 33 | if ($res || !$else) { 34 | return $res; 35 | } 36 | 37 | return is_callable($else) 38 | ? Plates\Util\obWrap($else) 39 | : (string) $else; 40 | }; 41 | } 42 | 43 | const START_APPEND = 0; 44 | const START_PREPEND = 1; 45 | const START_REPLACE = 2; 46 | 47 | /** Starts the output buffering for a section, update of 0 = replace, 1 = append, 2 = prepend */ 48 | function startFunc($update = START_REPLACE) 49 | { 50 | return startBufferFunc(fn (FuncArgs $args) => function ($contents) use ($update, $args) { 51 | $name = $args->args[0]; 52 | $sections = $args->template()->get('sections'); 53 | 54 | if ($update === START_APPEND) { 55 | $sections->append($name, $contents); 56 | } elseif ($update === START_PREPEND) { 57 | $sections->prepend($name, $contents); 58 | } else { 59 | $sections->add($name, $contents); 60 | } 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/Path/PathExtension.php: -------------------------------------------------------------------------------- 1 | getContainer(); 12 | $c->add('path.resolvePath.prefixes', fn ($c) => (array) ($c->get('config')['base_dir'] ?? [])); 13 | $c->addComposed('path.normalizeName', fn ($c) => [ 14 | 'path.stripExt' => stripExtNormalizeName(), 15 | 'path.stripPrefix' => stripPrefixNormalizeName($c->get('path.resolvePath.prefixes')), 16 | ]); 17 | $c->addStack('path.resolvePath', function ($c) { 18 | $config = $c->get('config'); 19 | $prefixes = $c->get('path.resolvePath.prefixes'); 20 | 21 | return array_filter([ 22 | 'path.id' => idResolvePath(), 23 | 'path.prefix' => $prefixes ? prefixResolvePath($prefixes, $c->get('fileExists')) : null, 24 | 'path.ext' => isset($config['ext']) ? extResolvePath($config['ext']) : null, 25 | 'path.relative' => relativeResolvePath(), 26 | ]); 27 | }); 28 | $plates->defineConfig([ 29 | 'ext' => 'phtml', 30 | 'base_dir' => null, 31 | ]); 32 | $plates->pushComposers(fn ($c) => [ 33 | 'path.normalizeName' => normalizeNameCompose($c->get('path.normalizeName')), 34 | 'path.resolvePath' => resolvePathCompose($c->get('path.resolvePath')), 35 | ]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/Path/ResolvePathArgs.php: -------------------------------------------------------------------------------- 1 | context, $this->template); 16 | } 17 | 18 | public function withContext(array $context) 19 | { 20 | return new self($this->path, $context, $this->template); 21 | } 22 | 23 | public static function fromTemplate(Template $template) 24 | { 25 | return new self($template->name, [], clone $template); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/Path/path.php: -------------------------------------------------------------------------------- 1 | $template->with('path', $resolve_path(ResolvePathArgs::fromTemplate($template))); 10 | } 11 | 12 | function normalizeNameCompose(callable $normalize_name) 13 | { 14 | return fn (Plates\Template $template) => $template->with( 15 | 'normalized_name', 16 | Plates\Util\isPath($template->name) ? $normalize_name($template->get('path')) : $template->name 17 | ); 18 | } 19 | 20 | function stripExtNormalizeName() 21 | { 22 | return function ($name) { 23 | $ext = pathinfo($name, PATHINFO_EXTENSION); 24 | if ($ext === '' || $ext === '0') { 25 | return $name; 26 | } 27 | 28 | return substr($name, 0, (strlen($ext) + 1) * -1); // +1 for the leading `.` 29 | }; 30 | } 31 | 32 | function stripPrefixNormalizeName(array $prefixes) 33 | { 34 | $prefixes = array_filter($prefixes); 35 | 36 | return function ($name) use ($prefixes) { 37 | foreach ($prefixes as $prefix) { 38 | if (str_starts_with($name, $prefix . '/')) { 39 | return substr($name, strlen($prefix) + 1); // +1 for the trailing `/` 40 | } 41 | } 42 | 43 | return $name; 44 | }; 45 | } 46 | 47 | /** appends an extension to the name */ 48 | function extResolvePath($ext = 'phtml') 49 | { 50 | $full_ext = '.' . $ext; 51 | $ext_len = strlen($full_ext); 52 | 53 | return function (ResolvePathArgs $args, $next) use ($full_ext, $ext_len) { 54 | // ext is already there, just skip 55 | if (strrpos($args->path, $full_ext) === strlen($args->path) - $ext_len) { 56 | return $next($args); 57 | } 58 | 59 | return $next($args->withPath($args->path . $full_ext)); 60 | }; 61 | } 62 | 63 | function prefixResolvePath(array $prefixes, $file_exists = 'file_exists') 64 | { 65 | return function (ResolvePathArgs $args, $next) use ($prefixes, $file_exists) { 66 | if ($prefixes === []) { 67 | return $next($args); 68 | } 69 | 70 | foreach ($prefixes as $cur_prefix) { 71 | $path = Plates\Util\isAbsolutePath($args->path) 72 | ? $next($args) 73 | : $next($args->withPath( 74 | Plates\Util\joinPath([$cur_prefix, $args->path]) 75 | )); 76 | 77 | // we have a match, let's return 78 | if ($file_exists($path)) { 79 | return $path; 80 | } 81 | 82 | // at this point, we need to try the next prefix, but before we do, let's strip the prefix 83 | // if there is one since this might a be a relative path 84 | $stripped_args = null; 85 | foreach ($prefixes as $prefix) { 86 | if (str_starts_with($path, $prefix)) { 87 | $stripped_args = $args->withPath(substr($path, strlen($prefix))); // remove the prefix 88 | break; 89 | } 90 | } 91 | 92 | // could not strip the prefix, so there's not point in continuing on 93 | if (!$stripped_args) { 94 | return $path; 95 | } 96 | 97 | $args = $stripped_args; 98 | } 99 | 100 | // at this point, none of the paths resolved into a valid path, let's just return the last one 101 | return $path; 102 | }; 103 | } 104 | 105 | /** Figures out the path based off of the parent templates current path */ 106 | function relativeResolvePath() 107 | { 108 | return function (ResolvePathArgs $args, $next) { 109 | $is_relative = Plates\Util\isRelativePath($args->path) && $args->template->parent; 110 | 111 | if (!$is_relative) { 112 | return $next($args); // nothing to do 113 | } 114 | 115 | $current_directory = dirname($args->template->parent()->get('path')); 116 | 117 | return $next($args->withPath( 118 | Plates\Util\joinPath([$current_directory, $args->path]) 119 | )); 120 | }; 121 | } 122 | 123 | function idResolvePath() 124 | { 125 | return fn (ResolvePathArgs $args, $next) => $args->path; 126 | } 127 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/RenderContext/FuncArgs.php: -------------------------------------------------------------------------------- 1 | ref->template; 16 | } 17 | 18 | public function withName($func_name) 19 | { 20 | return new self($this->render, $this->ref, $func_name, $this->args); 21 | } 22 | 23 | public function withArgs($args) 24 | { 25 | return new self($this->render, $this->ref, $this->func_name, $args); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/RenderContext/RenderContext.php: -------------------------------------------------------------------------------- 1 | func_stack = $func_stack ?: Plates\Util\stack([platesFunc()]); 18 | } 19 | 20 | public function __get($name) 21 | { 22 | if (!$this->func_stack) { 23 | throw new BadMethodCallException('Cannot access ' . $name . ' because no func stack has been setup.'); 24 | } 25 | 26 | return $this->invokeFuncStack($name, []); 27 | } 28 | 29 | public function __set($name, $value) 30 | { 31 | throw new BadMethodCallException('Cannot set ' . $name . ' on this render context.'); 32 | } 33 | 34 | public function __call($name, array $args) 35 | { 36 | if (!$this->func_stack) { 37 | throw new BadMethodCallException('Cannot call ' . $name . ' because no func stack has been setup.'); 38 | } 39 | 40 | return $this->invokeFuncStack($name, $args); 41 | } 42 | 43 | public function __invoke(array $args = []) 44 | { 45 | if (!$this->func_stack) { 46 | throw new BadMethodCallException('Cannot invoke the render context because no func stack has been setup.'); 47 | } 48 | 49 | return $this->invokeFuncStack('__invoke', $args); 50 | } 51 | 52 | private function invokeFuncStack($name, array $args) 53 | { 54 | return ($this->func_stack)(new FuncArgs( 55 | $this->render, 56 | $this->ref, 57 | $name, 58 | $args 59 | )); 60 | } 61 | 62 | public static function factory(callable $create_render, $func_stack = null) 63 | { 64 | return fn (Plates\TemplateReference $ref) => new self( 65 | $create_render(), 66 | $ref, 67 | $func_stack 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/RenderContext/RenderContextExtension.php: -------------------------------------------------------------------------------- 1 | getContainer(); 13 | $c->addStack('renderContext.func', fn ($c) => [ 14 | 'notFound' => notFoundFunc(), 15 | 'plates' => Plates\Util\stackGroup([ 16 | splitByNameFunc($c->get('renderContext.func.funcs')), 17 | aliasNameFunc($c->get('renderContext.func.aliases')), 18 | ]), 19 | ]); 20 | $c->add('renderContext.func.aliases', [ 21 | 'e' => 'escape', 22 | '__invoke' => 'escape', 23 | 'stop' => 'end', 24 | ]); 25 | $c->add('renderContext.func.funcs', function ($c) { 26 | $template_args = assertTemplateArgsFunc(); 27 | $one_arg = assertArgsFunc(1); 28 | $config = $c->get('config'); 29 | 30 | return [ 31 | 'insert' => [insertFunc(), $template_args], 32 | 'render' => [insertFunc(), $template_args], 33 | 'escape' => [ 34 | isset($config['escape_flags'], $config['escape_encoding']) 35 | ? escapeFunc($config['escape_flags'], $config['escape_encoding']) 36 | : escapeFunc(), 37 | $one_arg, 38 | ], 39 | 'data' => [templateDataFunc(), assertArgsFunc(0, 1)], 40 | 'name' => [accessTemplatePropFunc('name')], 41 | 'context' => [accessTemplatePropFunc('context')], 42 | 'component' => [componentFunc(), $template_args], 43 | 'slot' => [slotFunc(), $one_arg], 44 | 'end' => [endFunc()], 45 | ]; 46 | }); 47 | $c->add('include.bind', fn ($c) => renderContextBind()); 48 | $c->add('renderContext.factory', fn ($c) => RenderContext::factory( 49 | fn () => $c->get('renderTemplate'), 50 | $c->get('renderContext.func') 51 | )); 52 | 53 | $plates->defineConfig([ 54 | 'render_context_var_name' => 'v', 55 | 'escape_encoding' => null, 56 | 'escape_flags' => null, 57 | ]); 58 | $plates->pushComposers(fn ($c) => [ 59 | 'renderContext.renderContext' => renderContextCompose( 60 | $c->get('renderContext.factory'), 61 | $c->get('config')['render_context_var_name'] 62 | ), 63 | ]); 64 | 65 | $plates->addMethods([ 66 | 'registerFunction' => function (Plates\Engine $e, $name, callable $func, callable $assert_args = null, $simple = true) { 67 | $c = $e->getContainer(); 68 | $func = $simple ? wrapSimpleFunc($func) : $func; 69 | 70 | $c->wrap('renderContext.func.funcs', function ($funcs, $c) use ($name, $func, $assert_args) { 71 | $funcs[$name] = $assert_args ? [$assert_args, $func] : [$func]; 72 | 73 | return $funcs; 74 | }); 75 | }, 76 | 'addFuncs' => function (Plates\Engine $e, callable $add_funcs, $simple = false) { 77 | $e->getContainer()->wrap('renderContext.func.funcs', function ($funcs, $c) use ($add_funcs, $simple) { 78 | $new_funcs = $simple 79 | ? array_map(wrapSimpleFunc::class, $add_funcs($c)) 80 | : $add_funcs($c); 81 | 82 | return array_merge($funcs, $new_funcs); 83 | }); 84 | }, 85 | 'wrapFuncs' => function (Plates\Engine $e, callable $wrap_funcs) { 86 | $e->getContainer()->wrap('renderContext.func.funcs', $wrap_funcs); 87 | }, 88 | ]); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/RenderContext/func.php: -------------------------------------------------------------------------------- 1 | template()->get('component_slot_data') !== null) { 15 | throw new FuncException('Cannot nest component func calls.'); 16 | } 17 | 18 | $args->template()->with('component_slot_data', []); 19 | 20 | return function ($contents) use ($insert, $args) { 21 | [$name, $data] = $args->args; 22 | 23 | $data = array_merge( 24 | $data ?: [], 25 | ['slot' => $contents], 26 | $args->template()->get('component_slot_data') 27 | ); 28 | 29 | $insert($args->withArgs([$name, $data])); 30 | 31 | $args->template()->with('component_slot_data', null); 32 | }; 33 | }); 34 | } 35 | 36 | function slotFunc() 37 | { 38 | return startBufferFunc(function (FuncArgs $args) { 39 | if ($args->template()->get('component_slot_data') === null) { 40 | throw new FuncException('Cannot call slot func outside of component definition.'); 41 | } 42 | 43 | return function ($contents) use ($args) { 44 | $slot_data = $args->template()->get('component_slot_data'); 45 | $slot_data[$args->args[0]] = $contents; 46 | $args->template()->with('component_slot_data', $slot_data); 47 | }; 48 | }); 49 | } 50 | 51 | function startBufferFunc(callable $create_callback) 52 | { 53 | return function (FuncArgs $args) use ($create_callback) { 54 | $buffer_stack = $args->template()->get('buffer_stack') ?: []; 55 | 56 | ob_start(); 57 | $buffer_stack[] = [ob_get_level(), $create_callback($args)]; 58 | 59 | $args->template()->with('buffer_stack', $buffer_stack); 60 | }; 61 | } 62 | 63 | function endFunc() 64 | { 65 | return function (FuncArgs $args) { 66 | $buffer_stack = $args->template()->get('buffer_stack') ?: []; 67 | if ((is_countable($buffer_stack) ? count($buffer_stack) : 0) === 0) { 68 | throw new FuncException('Cannot end a section definition because no section has been started.'); 69 | } 70 | 71 | [$ob_level, $callback] = array_pop($buffer_stack); 72 | 73 | if ($ob_level != ob_get_level()) { 74 | throw new FuncException('Output buffering level does not match when section was started.'); 75 | } 76 | 77 | $contents = ob_get_clean(); 78 | 79 | $callback($contents); 80 | 81 | $args->template()->with('buffer_stack', $buffer_stack); 82 | }; 83 | } 84 | 85 | function insertFunc($echo = null) 86 | { 87 | $echo = $echo ?: Plates\Util\phpEcho(); 88 | 89 | return function (FuncArgs $args) use ($echo) { 90 | [$name, $data] = $args->args; 91 | $name = MagicResolver::resolve($name); 92 | $child = $args->template()->fork($name, $data ?: []); 93 | $echo($args->render->renderTemplate($child)); 94 | }; 95 | } 96 | 97 | function templateDataFunc() 98 | { 99 | return function (FuncArgs $args) { 100 | [$data] = $args->args; 101 | 102 | return array_merge($args->template()->data, $data); 103 | }; 104 | } 105 | 106 | /** Enables the backwards compatibility with the old extension functions */ 107 | function wrapSimpleFunc(callable $func, $enable_bc = false) 108 | { 109 | return function (FuncArgs $args) use ($func, $enable_bc) { 110 | if ($enable_bc && is_array($func) && isset($func[0]) && $func[0] instanceof Plates\Extension\ExtensionInterface) { 111 | $func[0]->template = $args->template(); 112 | } 113 | 114 | return $func(...$args->args); 115 | }; 116 | } 117 | 118 | function accessTemplatePropFunc($prop) 119 | { 120 | return fn (FuncArgs $args) => $args->template()->{$prop}; 121 | } 122 | 123 | function escapeFunc($flags = ENT_COMPAT | ENT_HTML401, $encoding = 'UTF-8') 124 | { 125 | return fn (FuncArgs $args) => htmlspecialchars($args->args[0], $flags, $encoding); 126 | } 127 | 128 | function assertArgsFunc($num_required, $num_default = 0) 129 | { 130 | return function (FuncArgs $args, $next) use ($num_required, $num_default) { 131 | if ((is_countable($args->args) ? count($args->args) : 0) < $num_required) { 132 | throw new FuncException(sprintf('Func %s has %s argument(s).', $args->func_name, $num_required)); 133 | } 134 | 135 | if ((is_countable($args->args) ? count($args->args) : 0) >= $num_required + $num_default) { 136 | return $next($args); 137 | } 138 | 139 | $args = $args->withArgs(array_merge($args->args, array_fill( 140 | 0, 141 | $num_required + $num_default - (is_countable($args->args) ? count($args->args) : 0), 142 | null 143 | ))); 144 | 145 | return $next($args); 146 | }; 147 | } 148 | 149 | function assertTemplateArgsFunc() 150 | { 151 | return assertArgsFunc(1, 2); 152 | } 153 | 154 | /** Creates aliases for certain functions */ 155 | function aliasNameFunc(array $aliases) 156 | { 157 | return function (FuncArgs $args, $next) use ($aliases) { 158 | if (!isset($aliases[$args->func_name])) { 159 | return $next($args); 160 | } 161 | 162 | while (isset($aliases[$args->func_name])) { 163 | $args = $args->withName($aliases[$args->func_name]); 164 | } 165 | 166 | return $next($args); 167 | }; 168 | } 169 | 170 | /** Allows splitting of the handlers from the args name */ 171 | function splitByNameFunc(array $handlers) 172 | { 173 | return function (FuncArgs $args, $next) use ($handlers) { 174 | $name = $args->func_name; 175 | if (isset($handlers[$name])) { 176 | $handler = Plates\Util\stackGroup($handlers[$name]); 177 | 178 | return $handler($args, $next); 179 | } 180 | 181 | return $next($args); 182 | }; 183 | } 184 | 185 | function notFoundFunc() 186 | { 187 | return function (FuncArgs $args) { 188 | throw new FuncException('The function ' . $args->func_name . ' does not exist.'); 189 | }; 190 | } 191 | -------------------------------------------------------------------------------- /src/View/Plates/Extension/RenderContext/render-context.php: -------------------------------------------------------------------------------- 1 | reference); 12 | 13 | return $template->withAddedData([ 14 | $var_name => $render_context, 15 | ])->with('render_context', $render_context); 16 | }; 17 | } 18 | 19 | function renderContextBind() 20 | { 21 | return fn (Closure $inc, Template $template) => $template->get('render_context') 22 | ? $inc->bindTo($template->get('render_context')) 23 | : $inc; 24 | } 25 | -------------------------------------------------------------------------------- /src/View/Plates/MagicResolver.php: -------------------------------------------------------------------------------- 1 | getContainer(); 10 | 11 | $c->add('config', [ 12 | 'validate_paths' => true, 13 | 'php_extensions' => ['php', 'phtml'], 14 | 'image_extensions' => ['png', 'jpg'], 15 | ]); 16 | $c->addComposed('compose', fn () => []); 17 | $c->add('fileExists', fn ($c) => 'file_exists'); 18 | $c->add('renderTemplate', function ($c) { 19 | $rt = new RenderTemplate\FileSystemRenderTemplate([ 20 | [ 21 | Template\matchExtensions($c->get('config')['php_extensions']), 22 | new RenderTemplate\PhpRenderTemplate($c->get('renderTemplate.bind')), 23 | ], 24 | [ 25 | Template\matchExtensions($c->get('config')['image_extensions']), 26 | RenderTemplate\MapContentRenderTemplate::base64Encode(new RenderTemplate\StaticFileRenderTemplate()), 27 | ], 28 | [ 29 | Template\matchStub(true), 30 | new RenderTemplate\StaticFileRenderTemplate(), 31 | ], 32 | ]); 33 | if ($c->get('config')['validate_paths']) { 34 | $rt = new RenderTemplate\ValidatePathRenderTemplate($rt, $c->get('fileExists')); 35 | } 36 | 37 | $rt = array_reduce($c->get('renderTemplate.factories'), fn ($rt, $create) => $create($rt), $rt); 38 | $rt = new RenderTemplate\ComposeRenderTemplate($rt, $c->get('compose')); 39 | 40 | return $rt; 41 | }); 42 | $c->add('renderTemplate.bind', fn () => Util\id()); 43 | $c->add('renderTemplate.factories', fn () => []); 44 | 45 | $plates->addMethods([ 46 | 'pushComposers' => function (Engine $e, $def_composer) { 47 | $e->getContainer()->wrapComposed('compose', fn ($composed, $c) => array_merge($composed, $def_composer($c))); 48 | }, 49 | 'unshiftComposers' => function (Engine $e, $def_composer) { 50 | $e->getContainer()->wrapComposed('compose', fn ($composed, $c) => array_merge($def_composer($c), $composed)); 51 | }, 52 | 'addConfig' => function (Engine $e, array $config) { 53 | $e->getContainer()->merge('config', $config); 54 | }, 55 | /* merges in config values, but will defer to values already set in the config */ 56 | 'defineConfig' => function (Engine $e, array $config_def) { 57 | $config = $e->getContainer()->get('config'); 58 | $e->getContainer()->add('config', array_merge($config_def, $config)); 59 | }, 60 | ]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/View/Plates/RenderTemplate.php: -------------------------------------------------------------------------------- 1 | compose = $compose; 14 | } 15 | 16 | public function renderTemplate(Plates\Template $template, ?Plates\RenderTemplate $rt = null) { 17 | return $this->render->renderTemplate(($this->compose)($template), $rt ?: $this); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/View/Plates/RenderTemplate/FileSystemRenderTemplate.php: -------------------------------------------------------------------------------- 1 | render_sets as [$match, $render]) { 15 | if ($match($template)) { 16 | return $render->renderTemplate($template, $rt ?: $this); 17 | } 18 | } 19 | 20 | throw new Plates\Exception\RenderTemplateException('No renderer was available for the template: ' . $template->name); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/View/Plates/RenderTemplate/MapContentRenderTemplate.php: -------------------------------------------------------------------------------- 1 | map_content = $map_content; 14 | } 15 | 16 | public function renderTemplate(Plates\Template $template, ?Plates\RenderTemplate $rt = null) { 17 | return ($this->map_content)($this->render->renderTemplate($template, $rt ?: $this)); 18 | } 19 | 20 | public static function base64Encode(Plates\RenderTemplate $render) { 21 | return new self($render, 'base64_encode'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/View/Plates/RenderTemplate/MockRenderTemplate.php: -------------------------------------------------------------------------------- 1 | mocks[$template->name])) { 15 | throw new Plates\Exception\RenderTemplateException('Mock include does not exist for name: ' . $template->name); 16 | } 17 | 18 | return $this->mocks[$template->name]($template); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/View/Plates/RenderTemplate/PhpRenderTemplate.php: -------------------------------------------------------------------------------- 1 | bind = $bind; 14 | } 15 | 16 | public function renderTemplate(Plates\Template $template, ?Plates\RenderTemplate $render = null) { 17 | if (!defined('FORME_PLATES_VALIDATION') || FORME_PLATES_VALIDATION) { 18 | Validator::validate($template); 19 | } 20 | $inc = self::createInclude(); 21 | $inc = $this->bind ? ($this->bind)($inc, $template) : $inc; 22 | 23 | return Plates\Util\obWrap(function() use ($inc, $template) { 24 | $inc($template->get('path'), $template->data); 25 | }); 26 | } 27 | 28 | private static function createInclude() { 29 | return function() { 30 | $funcGetArg = func_get_arg(1); 31 | extract($funcGetArg); 32 | include func_get_arg(0); 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/View/Plates/RenderTemplate/RenderTemplateDecorator.php: -------------------------------------------------------------------------------- 1 | get('path'); 17 | return ($this->get_contents)($path); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/View/Plates/RenderTemplate/ValidatePathRenderTemplate.php: -------------------------------------------------------------------------------- 1 | get('path'); 15 | if (!$path || ($this->file_exists)($path)) { 16 | return $this->render->renderTemplate($template, $rt ?: null); 17 | } 18 | 19 | throw new Plates\Exception\RenderTemplateException('Template path ' . $path . ' is not a valid path for template: ' . $template->name); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/View/Plates/Template.php: -------------------------------------------------------------------------------- 1 | reference = ($ref ?: new TemplateReference)->update($this); 19 | } 20 | 21 | public function with($key, $value) { 22 | return $this->withAddedAttributes([$key => $value]); 23 | } 24 | 25 | public function get($key, $default = null) { 26 | return array_key_exists($key, $this->attributes) 27 | ? $this->attributes[$key] 28 | : $default; 29 | } 30 | 31 | /** Returns the deferenced parent template */ 32 | public function parent() { 33 | return $this->parent !== null ? ($this->parent)() : null; 34 | } 35 | 36 | public function withName($name) { 37 | return new self($name, $this->data, $this->attributes, $this->reference, $this->parent); 38 | } 39 | 40 | public function withData(array $data) { 41 | return new self($this->name, $data, $this->attributes, $this->reference, $this->parent); 42 | } 43 | 44 | public function withAddedData(array $data) { 45 | return new self($this->name, array_merge($this->data, $data), $this->attributes, $this->reference, $this->parent); 46 | } 47 | 48 | public function withAttributes(array $attributes) { 49 | return new self($this->name, $this->data, $attributes, $this->reference, $this->parent); 50 | } 51 | 52 | public function withAddedAttributes(array $attributes) { 53 | return new self($this->name, $this->data, array_merge($this->attributes, $attributes), $this->reference, $this->parent); 54 | } 55 | 56 | public function __clone() { 57 | $this->reference = new TemplateReference(); 58 | } 59 | 60 | /** Create a new template based off of this current one */ 61 | public function fork($name, array $data = [], array $attributes = []) { 62 | return new self( 63 | $name, 64 | $data, 65 | $attributes, 66 | null, 67 | $this->reference 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/View/Plates/Template/match.php: -------------------------------------------------------------------------------- 1 | $match(pathinfo($path, PATHINFO_EXTENSION))); 10 | } 11 | 12 | function matchName($name) 13 | { 14 | return fn(Template $template) => $template->get('normalized_name', $template->name) == $name; 15 | } 16 | 17 | function matchExtensions(array $extensions) 18 | { 19 | return matchPathExtension(fn($ext) => in_array($ext, $extensions)); 20 | } 21 | 22 | function matchAttribute($attribute, callable $match) 23 | { 24 | return fn(Template $template) => $match($template->get($attribute)); 25 | } 26 | 27 | function matchStub($res) 28 | { 29 | return fn(Template $template) => $res; 30 | } 31 | 32 | function matchAny(array $matches) 33 | { 34 | return function (Template $template) use ($matches) { 35 | foreach ($matches as $match) { 36 | if ($match($template)) { 37 | return true; 38 | } 39 | } 40 | 41 | return false; 42 | }; 43 | } 44 | 45 | function matchAll(array $matches) 46 | { 47 | return function (Template $template) use ($matches) { 48 | foreach ($matches as $match) { 49 | if (!$match($template)) { 50 | return false; 51 | } 52 | } 53 | 54 | return true; 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/View/Plates/TemplateReference.php: -------------------------------------------------------------------------------- 1 | template; 13 | } 14 | 15 | public function update(Template $template) 16 | { 17 | $this->template = $template; 18 | 19 | return $this; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/View/Plates/Util/Container.php: -------------------------------------------------------------------------------- 1 | $val) { 15 | $c->add($key, $val); 16 | } 17 | 18 | return $c; 19 | } 20 | 21 | public function add($id, $value) 22 | { 23 | if (array_key_exists($id, $this->cached)) { 24 | throw new \LogicException('Cannot add service after it has been frozen.'); 25 | } 26 | 27 | $this->boxes[$id] = [$value, $value instanceof \Closure]; 28 | } 29 | 30 | public function addComposed($id, callable $define_composers) 31 | { 32 | $this->add($id, fn ($c) => compose(...array_values($c->get($id . '.composers')))); 33 | $this->add($id . '.composers', $define_composers); 34 | } 35 | 36 | public function wrapComposed($id, callable $wrapped) 37 | { 38 | $this->wrap($id . '.composers', $wrapped); 39 | } 40 | 41 | public function addStack($id, callable $define_stack) 42 | { 43 | $this->add($id, fn ($c) => stack($c->get($id . '.stack'))); 44 | $this->add($id . '.stack', $define_stack); 45 | } 46 | 47 | public function wrapStack($id, callable $wrapped) 48 | { 49 | $this->wrap($id . '.stack', $wrapped); 50 | } 51 | 52 | public function merge($id, array $values) 53 | { 54 | $old = $this->get($id); 55 | $this->add($id, array_merge($old, $values)); 56 | } 57 | 58 | public function wrap($id, $wrapper) 59 | { 60 | if (!$this->has($id)) { 61 | throw new \LogicException('Cannot wrap service ' . $id . ' that does not exist.'); 62 | } 63 | 64 | $box = $this->boxes[$id]; 65 | $this->boxes[$id] = [fn ($c) => $wrapper($this->unbox($box, $c), $c), true]; 66 | } 67 | 68 | public function get($id) 69 | { 70 | if (array_key_exists($id, $this->cached)) { 71 | return $this->cached[$id]; 72 | } 73 | 74 | if (!$this->has($id)) { 75 | throw new \LogicException('Cannot retrieve service ' . $id . ' that does exist.'); 76 | } 77 | 78 | $result = $this->unbox($this->boxes[$id], $this); 79 | if ($this->boxes[$id][1]) { // only cache services 80 | $this->cached[$id] = $result; 81 | } 82 | 83 | return $result; 84 | } 85 | 86 | public function has($id) 87 | { 88 | return array_key_exists($id, $this->boxes); 89 | } 90 | 91 | private function unbox($box, Container $c) 92 | { 93 | [$value, $is_factory] = $box; 94 | if (!$is_factory) { 95 | return $value; 96 | } 97 | 98 | return $value($c); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/View/Plates/Util/util.php: -------------------------------------------------------------------------------- 1 | $arg; 11 | } 12 | 13 | /** wraps a closure in output buffering and returns the buffered 14 | content. */ 15 | function obWrap(callable $wrap) 16 | { 17 | $cur_level = ob_get_level(); 18 | 19 | try { 20 | ob_start(); 21 | $wrap(); 22 | 23 | return ob_get_clean(); 24 | } catch (\Exception|\Throwable $e) { 25 | } 26 | 27 | // clean the ob stack 28 | while (ob_get_level() > $cur_level) { 29 | ob_end_clean(); 30 | } 31 | 32 | throw $e; 33 | } 34 | 35 | /** simple utility that wraps php echo which allows for stubbing out the 36 | echo func for testing */ 37 | function phpEcho() 38 | { 39 | return function ($v) { 40 | echo $v; 41 | }; 42 | } 43 | 44 | /** stack a set of functions into each other and returns the stacked func */ 45 | function stack(array $funcs) 46 | { 47 | return array_reduce($funcs, fn ($next, $func) => function (...$args) use ($next, $func) { 48 | $args[] = $next; 49 | 50 | return $func(...$args); 51 | }, function () { 52 | throw new StackException('No handler was able to return a result.'); 53 | }); 54 | } 55 | 56 | function stackGroup(array $funcs) 57 | { 58 | $end_next = null; 59 | array_unshift($funcs, function (...$args) use (&$end_next) { 60 | return $end_next(...array_slice($args, 0, -1)); 61 | }); 62 | $next = stack($funcs); 63 | 64 | return function (...$args) use ($next, &$end_next) { 65 | $end_next = end($args); 66 | 67 | return $next(...array_slice($args, 0, -1)); 68 | }; 69 | } 70 | 71 | /** compose(f, g)(x) = f(g(x)) */ 72 | function compose(...$funcs) 73 | { 74 | return pipe(...array_reverse($funcs)); 75 | } 76 | 77 | /** pipe(f, g)(x) = g(f(x)) */ 78 | function pipe(...$funcs) 79 | { 80 | return fn ($arg) => array_reduce($funcs, fn ($acc, $func) => $func($acc), $arg); 81 | } 82 | 83 | function joinPath(array $parts, $sep = DIRECTORY_SEPARATOR) 84 | { 85 | return array_reduce(array_filter($parts), function ($acc, $part) use ($sep) { 86 | if ($acc === null) { 87 | return rtrim($part, $sep); 88 | } 89 | 90 | return $acc . $sep . ltrim($part, $sep); 91 | }, null); 92 | } 93 | 94 | function isAbsolutePath($path) 95 | { 96 | return str_starts_with($path, '/'); 97 | } 98 | 99 | function isRelativePath($path) 100 | { 101 | return str_starts_with($path, './') || str_starts_with($path, '../'); 102 | } 103 | 104 | function isResourcePath($path) 105 | { 106 | return str_contains($path, '://'); 107 | } 108 | 109 | function isPath($path) 110 | { 111 | return isAbsolutePath($path) || isRelativePath($path) || isResourcePath($path); 112 | } 113 | 114 | /** returns the debug type of an object as string for exception printing */ 115 | function debugType($v) 116 | { 117 | if (is_object($v)) { 118 | return 'object ' . $v::class; 119 | } 120 | 121 | return gettype($v); 122 | } 123 | 124 | function spliceArrayAtKey(array $array, $key, array $values, $after = true) 125 | { 126 | $new_array = []; 127 | $spliced = false; 128 | foreach ($array as $array_key => $val) { 129 | if ($array_key == $key) { 130 | $spliced = true; 131 | if ($after) { 132 | $new_array[$array_key] = $val; 133 | $new_array = array_merge($new_array, $values); 134 | } else { 135 | $new_array = array_merge($new_array, $values); 136 | $new_array[$array_key] = $val; 137 | } 138 | } else { 139 | $new_array[$array_key] = $val; 140 | } 141 | } 142 | 143 | if (!$spliced) { 144 | throw new PlatesException('Could not find key ' . $key . ' in array.'); 145 | } 146 | 147 | return $new_array; 148 | } 149 | 150 | function cachedFileExists(Psr\SimpleCache\CacheInterface $cache, $ttl = 3600, $file_exists = 'file_exists') 151 | { 152 | return function ($path) use ($cache, $ttl, $file_exists) { 153 | $key = 'League.Plates.file_exists.' . $path; 154 | $res = $cache->get($key); 155 | if (!$res) { 156 | $res = $file_exists($path); 157 | $cache->set($key, $res, $ttl); 158 | } 159 | 160 | return $res; 161 | }; 162 | } 163 | 164 | /** Invokes the callable if the predicate returns true. Returns the value otherwise. */ 165 | function when(callable $predicate, callable $fn) 166 | { 167 | return fn ($arg) => $predicate($arg) ? $fn($arg) : $arg; 168 | } 169 | -------------------------------------------------------------------------------- /src/View/Plates/Validator.php: -------------------------------------------------------------------------------- 1 | '/\s*if\s*\(.*\)\s*\{.*\}/m', 14 | 'message' => 'Single line if statements are not allowed in Plates. Use a ternary or split up the statement.', 15 | ], 16 | [ 17 | 'pattern' => '/\s*echo\s*/m', 18 | 'message' => 'Echo statements are not allowed in Plates. Use " '/(?=.*<\?php)(?!.*\?\>)/m', 22 | 'message' => 'Multiline php statements are not allowed in Plates. Split this up or extract the logic.', 23 | ], 24 | [ 25 | 'pattern' => '/.*<\?php.*;\s*[^\s]+\?\>/m', 26 | 'message' => 'Multiple php statements on a single line are not allowed in Plates. Split this up or extract the logic.', 27 | ], 28 | [ 29 | 'pattern' => '/\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*\s*=[^[>=<]]/m', 30 | 'message' => 'Variable assignments are not allowed in Plates. Move to a controller or extract into a helper function.', 31 | ], 32 | [ 33 | 'pattern' => '/(if|foreach|switch|for|while)\s*\(.*\).*\n*.*\{/m', 34 | 'message' => 'Plates does not allow curly brackets in control structures. You must use short syntax.', 35 | ], 36 | ]; 37 | 38 | public static function validate(Template $template): void 39 | { 40 | // get the contents of the template 41 | $path = $template->get('path'); 42 | 43 | self::validateFile($path); 44 | } 45 | 46 | public static function validateFile(string $path): void 47 | { 48 | $contents = file_get_contents($path); 49 | array_map(function (array $rule) use ($contents, $path) { 50 | $errorLine = self::checkAndReturnLine($rule['pattern'], $contents); 51 | if ($errorLine) { 52 | throw new TemplateErrorException(message: $rule['message'], line: $errorLine, filename: $path); 53 | } 54 | }, self::RULES); 55 | } 56 | 57 | private static function checkAndReturnLine(string $pattern, string $content): ?int 58 | { 59 | $match = preg_match(pattern: $pattern, subject: $content, matches: $matches, flags: PREG_OFFSET_CAPTURE); 60 | 61 | if (!$match) { 62 | return null; 63 | } 64 | 65 | $characterPosition = (int) $matches[0][1]; 66 | 67 | if ($characterPosition === 0) { 68 | return 1; 69 | } 70 | 71 | [$before] = str_split($content, $characterPosition); 72 | 73 | return strlen($before) - strlen(str_replace("\n", '', $before)) + 1; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/View/PlatesView.php: -------------------------------------------------------------------------------- 1 | view = Engine::create($this->getDir() . self::RELATIVE_VIEW_DIR, 'plate.php'); 19 | } 20 | 21 | public function render(string $template, array $context = []): string 22 | { 23 | return $this->view->render($template, $context); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/View/TwigAdhocFunction.php: -------------------------------------------------------------------------------- 1 | wp_get_current_user(), 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/View/TwigView.php: -------------------------------------------------------------------------------- 1 | getDir() . '/../../views'); 19 | $options = []; 20 | if (WP_ENV==='production') { 21 | $options['cache'] = FORME_PRIVATE_ROOT . 'view-cache'; 22 | } 23 | 24 | $this->view = new Environment($loader, $options); 25 | $this->view->addExtension(getInstance(TwigExtension::class)); 26 | } 27 | 28 | public function render(string $template, array $context = []): string 29 | { 30 | $template = str_replace('.', '/', $template); 31 | 32 | return $this->view->render($template . '.twig', $context); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/View/ViewInterface.php: -------------------------------------------------------------------------------- 1 | token= new Token(); 9 | }); 10 | 11 | it('returns a uuid token', function () { 12 | $result = $this->token->get('test'); 13 | expect($result)->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/'); 14 | }); 15 | 16 | it('returns an existing token', function () { 17 | $initial = $this->token->get('test'); 18 | $result = $this->token->get('test'); 19 | expect($result)->toBe($initial); 20 | }); 21 | 22 | it('validates a token', function () { 23 | $result = $this->token->get('test'); 24 | expect($this->token->validate($result, 'test'))->toBe(true); 25 | expect($this->token->validate('invalid-garbage-token', 'test'))->toBe(false); 26 | }); 27 | 28 | it('invalidates a token after default expiry', function () { 29 | $result = $this->token->get('test'); 30 | // expire the token by setting it to 2 hours ago 31 | $expiry = strtotime('-2 hours', time()); 32 | $dt = new DateTime(); 33 | $dt->setTimestamp($expiry); 34 | Capsule::table('forme_auth_tokens')->where('name', '=', 'test')->update(['expiry' => $dt->format('Y-m-d H:i:s')]); 35 | expect($this->token->validate($result, 'test'))->toBe(false); 36 | }); 37 | 38 | it('returns a new token after default expiry', function () { 39 | $result = $this->token->get('test'); 40 | // expire the token by setting it to 2 hours ago 41 | $expiry = strtotime('-2 hours', time()); 42 | $dt = new DateTime(); 43 | $dt->setTimestamp($expiry); 44 | Capsule::table('forme_auth_tokens')->where('name', '=', 'test')->update(['expiry' => $dt->format('Y-m-d H:i:s')]); 45 | expect($this->token->get('test'))->not->toBe($result); 46 | }); 47 | 48 | it('invalidates a token after custom expiry', function () { 49 | $result = $this->token->get('test', '+1 week'); 50 | // expire the token by setting it to 1 week ago 51 | $expiry = strtotime('-1 week', time()); 52 | $dt = new DateTime(); 53 | $dt->setTimestamp($expiry); 54 | Capsule::table('forme_auth_tokens')->where('name', '=', 'test')->update(['expiry' => $dt->format('Y-m-d H:i:s')]); 55 | expect($this->token->validate($result, 'test'))->toBe(false); 56 | }); 57 | 58 | it('returns a new token after custom expiry', function () { 59 | $result = $this->token->get('test', '+1 month'); 60 | // expire the token by setting it to 1 month ago 61 | $expiry = strtotime('-1 month', time()); 62 | $dt = new DateTime(); 63 | $dt->setTimestamp($expiry); 64 | Capsule::table('forme_auth_tokens')->where('name', '=', 'test')->update(['expiry' => $dt->format('Y-m-d H:i:s')]); 65 | expect($this->token->get('test'))->not->toBe($result); 66 | }); 67 | 68 | it('destroys all tokens', function () { 69 | $this->token->get('test'); 70 | $this->token->destroy('test'); 71 | expect(Capsule::table('forme_auth_tokens')->where('name', '=', 'test')->where('deleted_at', '=', null)->count())->toBe(0); 72 | }); 73 | 74 | it('tells you when the token expires', function () { 75 | $this->token->get('test'); 76 | expect($this->token->expires('test'))->not->toBe(null); 77 | expect($this->token->expires('test'))->toBeInstanceOf(Carbon::class); 78 | expect($this->token->expires('test')->getTimestamp())->toBeGreaterThan(time()); 79 | }); 80 | -------------------------------------------------------------------------------- /tests/Integration/ContainerHelpersTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(Container::class); 12 | }); 13 | 14 | it('returns a logger instance', function () { 15 | expect(log())->toBeInstanceOf(Logger::class); 16 | }); 17 | 18 | it('returns a singleton instance of a class', function () { 19 | $first = getInstance(stdClass::class); 20 | $first->foo = faker()->text(); 21 | $second = getInstance(stdClass::class); 22 | expect($second->foo)->toBe($first->foo); 23 | }); 24 | 25 | it('returns a new instance of a class', function () { 26 | $first = makeInstance(stdClass::class); 27 | $first->foo = faker()->text(); 28 | $second = getInstance(stdClass::class); 29 | $second->foo = faker()->text(); 30 | $third = makeInstance(stdClass::class); 31 | $third->foo = faker()->text(); 32 | expect($second->foo)->not()->toBe($first->foo); 33 | expect($third->foo)->not()->toBe($first->foo); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/Integration/Core/DistAssetsTest.php: -------------------------------------------------------------------------------- 1 | rootDir = dirname(__FILE__, 4) . '/wp-test' . THEME_DIR; 11 | // put a file and a manifest in assets/dist 12 | mkdir($this->rootDir . ASSETS_DIR, 0777, true); 13 | file_put_contents($this->rootDir . ASSETS_DIR . '/' . DUMMY_FILE, 'dummy'); 14 | file_put_contents($this->rootDir 15 | . '/assets/dist/manifest.json', '{"assets/dist/' . DUMMY_FILE . '": "..' . THEME_DIR . ASSETS_DIR . '/' . DUMMY_FILE . '"}'); 16 | }); 17 | 18 | afterEach(function () { 19 | // remove the files in assets 20 | unlink($this->rootDir . ASSETS_DIR . '/' . DUMMY_FILE); 21 | unlink($this->rootDir . ASSETS_DIR . '/manifest.json'); 22 | rmdir($this->rootDir . ASSETS_DIR); 23 | }); 24 | 25 | test('time() returns the file time of the file', function () { 26 | expect(Assets::time(DUMMY_FILE))->toBe((string) filemtime($this->rootDir . '/assets/dist/dummy.txt')); 27 | }); 28 | 29 | test('path() returns the absolute path of the file', function () { 30 | expect(Assets::path(DUMMY_FILE))->toBe($this->rootDir . '/assets/dist/dummy.txt'); 31 | }); 32 | 33 | test('uri() returns the uri of the file', function () { 34 | expect(Assets::uri(DUMMY_FILE))->toBe('../public/wp-content/themes/twentytwentyfour/assets/dist/dummy.txt'); 35 | }); 36 | 37 | test('distExists() returns true if dist folder exists', function () { 38 | expect(Assets::distExists())->toBe(true); 39 | }); 40 | 41 | test('staticExists() returns false if no static folder exists', function () { 42 | expect(Assets::staticExists())->toBe(false); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/Integration/Core/LoaderTest.php: -------------------------------------------------------------------------------- 1 | addAction('action_name', stdClass::class, 'method_name'); 10 | // get actions by reflection 11 | $actions = (new ReflectionClass($loader))->getProperty('actions'); 12 | $actions->setAccessible(true); 13 | $actions = $actions->getValue($loader); 14 | expect($actions)->toHaveLength(1); 15 | expect($actions[0]['hook'])->toBe('action_name'); 16 | expect($actions[0]['resolvedCallable'][0])->toBeInstanceOf(stdClass::class); 17 | expect($actions[0]['resolvedCallable'][1])->toBe('method_name'); 18 | expect($actions[0]['priority'])->toBe(10); 19 | expect($actions[0]['numberOfArgs'])->toBe(1); 20 | }); 21 | 22 | test('adds an invokable action to be registered with WordPress', function () { 23 | $loader = new Loader(); 24 | // mock invokeable class 25 | $class = new class() { 26 | public function __invoke(): string 27 | { 28 | return 'foo bar'; 29 | } 30 | }; 31 | $loader->addAction('action_name', get_class($class), '__invoke'); 32 | // get actions by reflection 33 | $actions = (new ReflectionClass($loader))->getProperty('actions'); 34 | $actions->setAccessible(true); 35 | $actions = $actions->getValue($loader); 36 | expect($actions)->toHaveLength(1); 37 | expect($actions[0]['hook'])->toBe('action_name'); 38 | expect($actions[0]['resolvedCallable'])->toBeInstanceOf(get_class($class)); 39 | expect($actions[0]['priority'])->toBe(10); 40 | expect($actions[0]['numberOfArgs'])->toBe(1); 41 | }); 42 | 43 | test('adds a filter to be registered with WordPress', function () { 44 | $loader = new Loader(); 45 | $loader->addFilter('filter_name', stdClass::class, 'method_name'); 46 | // get filters by reflection 47 | $filters = (new ReflectionClass($loader))->getProperty('filters'); 48 | $filters->setAccessible(true); 49 | $filters = $filters->getValue($loader); 50 | expect($filters)->toHaveLength(1); 51 | expect($filters[0]['hook'])->toBe('filter_name'); 52 | expect($filters[0]['resolvedCallable'][0])->toBeInstanceOf(stdClass::class); 53 | expect($filters[0]['resolvedCallable'][1])->toBe('method_name'); 54 | expect($filters[0]['priority'])->toBe(10); 55 | expect($filters[0]['numberOfArgs'])->toBe(1); 56 | }); 57 | 58 | test('adds an invokable filter to be registered with WordPress', function () { 59 | $loader = new Loader(); 60 | // mock invokeable class 61 | $class = new class() { 62 | public function __invoke(): string 63 | { 64 | return 'foo bar'; 65 | } 66 | }; 67 | $loader->addFilter('filter_name', get_class($class), '__invoke'); 68 | // get filters by reflection 69 | $filters = (new ReflectionClass($loader))->getProperty('filters'); 70 | $filters->setAccessible(true); 71 | $filters = $filters->getValue($loader); 72 | expect($filters)->toHaveLength(1); 73 | expect($filters[0]['hook'])->toBe('filter_name'); 74 | expect($filters[0]['resolvedCallable'])->toBeInstanceOf(get_class($class)); 75 | expect($filters[0]['priority'])->toBe(10); 76 | expect($filters[0]['numberOfArgs'])->toBe(1); 77 | }); 78 | 79 | test('adds a priority to a filter or action', function () { 80 | $loader = new Loader(); 81 | $loader->addFilter('filter_name', stdClass::class, 'method_name', 20); 82 | $loader->addAction('action_name', stdClass::class, 'method_name', 20); 83 | // get filters by reflection 84 | $filters = (new ReflectionClass($loader))->getProperty('filters'); 85 | $filters->setAccessible(true); 86 | $filters = $filters->getValue($loader); 87 | expect($filters[0]['priority'])->toBe(20); 88 | // get actions by reflection 89 | $actions = (new ReflectionClass($loader))->getProperty('actions'); 90 | $actions->setAccessible(true); 91 | $actions = $actions->getValue($loader); 92 | expect($actions[0]['priority'])->toBe(20); 93 | }); 94 | 95 | test('adds a number of arguments to a filter or action', function () { 96 | $loader = new Loader(); 97 | $loader->addFilter('filter_name', stdClass::class, 'method_name', 10, 2); 98 | $loader->addAction('action_name', stdClass::class, 'method_name', 10, 2); 99 | // get filters by reflection 100 | $filters = (new ReflectionClass($loader))->getProperty('filters'); 101 | $filters->setAccessible(true); 102 | $filters = $filters->getValue($loader); 103 | expect($filters[0]['numberOfArgs'])->toBe(2); 104 | // get actions by reflection 105 | $actions = (new ReflectionClass($loader))->getProperty('actions'); 106 | $actions->setAccessible(true); 107 | $actions = $actions->getValue($loader); 108 | expect($actions[0]['numberOfArgs'])->toBe(2); 109 | }); 110 | 111 | test('adds a yaml config file of hooks to be registered with WordPress', function () { 112 | $loader = new Loader(); 113 | $configString = file_get_contents(__DIR__ . '/hooks_test.yaml'); 114 | $loader->addConfig($configString); 115 | // get actions by reflection 116 | $actions = (new ReflectionClass($loader))->getProperty('actions'); 117 | $actions->setAccessible(true); 118 | $actions = $actions->getValue($loader); 119 | expect($actions)->toHaveLength(1); 120 | expect($actions[0]['hook'])->toBe('action_name'); 121 | expect($actions[0]['resolvedCallable'][0])->toBeInstanceOf(stdClass::class); 122 | expect($actions[0]['resolvedCallable'][1])->toBe('method_name'); 123 | expect($actions[0]['priority'])->toBe(10); 124 | expect($actions[0]['numberOfArgs'])->toBe(1); 125 | // get filters by reflection 126 | $filters = (new ReflectionClass($loader))->getProperty('filters'); 127 | $filters->setAccessible(true); 128 | $filters = $filters->getValue($loader); 129 | expect($filters)->toHaveLength(1); 130 | expect($filters[0]['hook'])->toBe('filter_name'); 131 | expect($filters[0]['resolvedCallable'][0])->toBeInstanceOf(stdClass::class); 132 | expect($filters[0]['resolvedCallable'][1])->toBe('method_name'); 133 | expect($filters[0]['priority'])->toBe(1); 134 | expect($filters[0]['numberOfArgs'])->toBe(2); 135 | }); 136 | 137 | test('run adds configured hooks to WordPress', function () { 138 | $loader = new Loader(); 139 | $configString = file_get_contents(__DIR__ . '/hooks_test.yaml'); 140 | $loader->addConfig($configString); 141 | $loader->run(); 142 | expect(has_action('action_name'))->toBe(true); 143 | expect(has_filter('filter_name'))->toBe(true); 144 | }); 145 | -------------------------------------------------------------------------------- /tests/Integration/Core/StaticAssetsTest.php: -------------------------------------------------------------------------------- 1 | rootDir = dirname(__FILE__, 4) . '/wp-test/public/wp-content/themes/twentytwentyfour'; 7 | // put a file in assets 8 | mkdir($this->rootDir . '/assets/static', 0777, true); 9 | file_put_contents($this->rootDir . '/assets/static/dummy.txt', 'dummy'); 10 | }); 11 | 12 | afterEach(function () { 13 | // remove the file in assets 14 | unlink($this->rootDir . '/assets/static/dummy.txt'); 15 | rmdir($this->rootDir . '/assets/static'); 16 | }); 17 | 18 | test('time() returns the file time of the file', function () { 19 | expect(Assets::time('dummy.txt'))->toBe((string) filemtime($this->rootDir . '/assets/static/dummy.txt')); 20 | }); 21 | 22 | test('path() returns the absolute path of the file', function () { 23 | expect(Assets::path('dummy.txt'))->toBe($this->rootDir . '/assets/static/dummy.txt'); 24 | }); 25 | 26 | test('uri() returns the uri of the file', function () { 27 | expect(Assets::uri('dummy.txt'))->toBe(get_template_directory_uri() . '/assets/static/dummy.txt'); 28 | }); 29 | 30 | test('distExists() returns false if no dist folder exists', function () { 31 | expect(Assets::distExists())->toBe(false); 32 | }); 33 | 34 | test('staticExists() returns true if a static folder exists', function () { 35 | expect(Assets::staticExists())->toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/Integration/Core/hooks_test.yaml: -------------------------------------------------------------------------------- 1 | actions: 2 | - hook: action_name 3 | class: stdClass 4 | method: method_name 5 | filters: 6 | - hook: filter_name 7 | class: stdClass 8 | method: method_name 9 | priority: 1 10 | arguments: 2 11 | -------------------------------------------------------------------------------- /tests/Integration/Hooks/HookIsSetTest.php: -------------------------------------------------------------------------------- 1 | toBe(true); 10 | }); 11 | 12 | test('returns false if a class hook is not already set', function () { 13 | expect(HookIsSet::check('template_include', TemplateHandler::class))->toBe(false); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/Integration/Router/StrategyFactoryTest.php: -------------------------------------------------------------------------------- 1 | factory = getContainer()->get(StrategyFactory::class); 10 | }); 11 | 12 | test('successfully creates all the configured route types', function () { 13 | $types = $this->factory::TYPES; 14 | foreach ($types as $type) { 15 | $strategy = $this->factory->get($type); 16 | expect($strategy)->toBeInstanceOf(StrategyInterface::class); 17 | } 18 | }); 19 | 20 | test('configured route types match the existing class files', function () { 21 | $strategyClassFiles = glob(__DIR__ . '/../../../src/Router/Strategy/*Strategy.php'); 22 | $strategyClasses = array_map(function ($file) { 23 | return basename($file, '.php'); 24 | }, $strategyClassFiles); 25 | $configuredTypes = $this->factory::TYPES; 26 | $configuredTypeClasses = array_map(function ($type) { 27 | return (new UnicodeString($type))->camel()->title()->toString() . 'Strategy'; 28 | }, $configuredTypes); 29 | // check that each array contains the same values 30 | expect(array_diff($strategyClasses, $configuredTypeClasses))->toBeEmpty(); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | beforeEach(function () { 5 | global $wp_filter; 6 | $wp_filter = []; 7 | migrate(); 8 | })->in('Integration'); 9 | 10 | uses()->afterEach(function () { 11 | rollback(); 12 | })->in('Integration'); 13 | -------------------------------------------------------------------------------- /tests/Unit/ExampleTest.php: -------------------------------------------------------------------------------- 1 | toBe(4); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |