diff --git a/.github/workflows/split-monorepo.yml b/.github/workflows/split-monorepo.yml index 90dac65a081..bf45381112b 100644 --- a/.github/workflows/split-monorepo.yml +++ b/.github/workflows/split-monorepo.yml @@ -65,6 +65,8 @@ jobs: - name: Commit and push changes env: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + COMMIT_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} run: | cd hyde if ! [[ `git status --porcelain` ]]; then @@ -72,12 +74,12 @@ jobs: exit 0; fi - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add upstream https://oauth2:${{ secrets.SPLIT_MONOREPO_TOKEN }}@github.com/hydephp/hyde.git git add . - git commit -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" + git commit --author="$COMMIT_AUTHOR_NAME <$COMMIT_AUTHOR_EMAIL>" -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" git push upstream develop echo "No changes to this package. Exiting gracefully." @@ -114,6 +116,8 @@ jobs: - name: Commit and push changes env: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + COMMIT_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} run: | cd framework if ! [[ `git status --porcelain` ]]; then @@ -121,12 +125,12 @@ jobs: exit 0; fi - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add upstream https://oauth2:${{ secrets.SPLIT_MONOREPO_TOKEN }}@github.com/hydephp/framework.git git add . - git commit -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" + git commit --author="$COMMIT_AUTHOR_NAME <$COMMIT_AUTHOR_EMAIL>" -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" git push upstream develop echo "No changes to this package. Exiting gracefully." @@ -163,6 +167,8 @@ jobs: - name: Commit and push changes env: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + COMMIT_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} run: | cd realtime-compiler if ! [[ `git status --porcelain` ]]; then @@ -170,12 +176,12 @@ jobs: exit 0; fi - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add upstream https://oauth2:${{ secrets.SPLIT_MONOREPO_TOKEN }}@github.com/hydephp/realtime-compiler.git git add . - git commit -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" + git commit --author="$COMMIT_AUTHOR_NAME <$COMMIT_AUTHOR_EMAIL>" -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" git push upstream master @@ -211,6 +217,8 @@ jobs: - name: Commit and push changes env: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + COMMIT_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} run: | cd hydefront if ! [[ `git status --porcelain` ]]; then @@ -218,12 +226,12 @@ jobs: exit 0; fi - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add upstream https://oauth2:${{ secrets.SPLIT_MONOREPO_TOKEN }}@github.com/hydephp/hydefront.git git add . - git commit -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" + git commit --author="$COMMIT_AUTHOR_NAME <$COMMIT_AUTHOR_EMAIL>" -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" git push upstream master @@ -263,6 +271,8 @@ jobs: - name: Commit and push changes env: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + COMMIT_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} run: | cd website if ! [[ `git status --porcelain` ]]; then @@ -270,12 +280,12 @@ jobs: exit 0; fi - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add upstream https://oauth2:${{ secrets.SPLIT_MONOREPO_TOKEN }}@github.com/hydephp/hydephp.com.git git add . - git commit -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" + git commit --author="$COMMIT_AUTHOR_NAME <$COMMIT_AUTHOR_EMAIL>" -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" git push upstream upcoming echo "No changes to this package. Exiting gracefully." @@ -312,6 +322,8 @@ jobs: - name: Commit and push changes env: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + COMMIT_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} run: | cd testing if ! [[ `git status --porcelain` ]]; then @@ -319,12 +331,12 @@ jobs: exit 0; fi - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add upstream https://oauth2:${{ secrets.SPLIT_MONOREPO_TOKEN }}@github.com/hydephp/testing.git git add . - git commit -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" + git commit --author="$COMMIT_AUTHOR_NAME <$COMMIT_AUTHOR_EMAIL>" -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" git push upstream master @@ -360,6 +372,8 @@ jobs: - name: Commit and push changes env: COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name }} + COMMIT_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} run: | cd ui-kit @@ -368,11 +382,11 @@ jobs: exit 0; fi - git config user.name github-actions - git config user.email github-actions@github.com + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote add upstream https://oauth2:${{ secrets.SPLIT_MONOREPO_TOKEN }}@github.com/hydephp/ui-kit.git git add . - git commit -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" + git commit --author="$COMMIT_AUTHOR_NAME <$COMMIT_AUTHOR_EMAIL>" -m "$COMMIT_MESSAGE https://github.com/hydephp/develop/commit/${{ github.sha }}" git push upstream master diff --git a/composer.lock b/composer.lock index a841a15d206..0d0432a0ac6 100644 --- a/composer.lock +++ b/composer.lock @@ -2707,16 +2707,16 @@ }, { "name": "league/commonmark", - "version": "2.5.3", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "b650144166dfa7703e62a22e493b853b58d874b0" + "reference": "d150f911e0079e90ae3c106734c93137c184f932" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/b650144166dfa7703e62a22e493b853b58d874b0", - "reference": "b650144166dfa7703e62a22e493b853b58d874b0", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d150f911e0079e90ae3c106734c93137c184f932", + "reference": "d150f911e0079e90ae3c106734c93137c184f932", "shasum": "" }, "require": { @@ -2741,8 +2741,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 || ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 || ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0" }, @@ -2752,7 +2753,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.6-dev" + "dev-main": "2.7-dev" } }, "autoload": { @@ -2809,7 +2810,7 @@ "type": "tidelift" } ], - "time": "2024-08-16T11:46:16+00:00" + "time": "2024-12-07T15:34:16+00:00" }, { "name": "league/config", @@ -4581,16 +4582,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", "shasum": "" }, "require": { @@ -4628,7 +4629,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" }, "funding": [ { @@ -4644,7 +4645,7 @@ "type": "tidelift" } ], - "time": "2024-04-18T09:32:20+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/error-handler", diff --git a/docs/_data/partials/hyde-pages-api/hyde-kernel-string-methods.md b/docs/_data/partials/hyde-pages-api/hyde-kernel-string-methods.md index 4659a1ed2f2..60c1f74ed80 100644 --- a/docs/_data/partials/hyde-pages-api/hyde-kernel-string-methods.md +++ b/docs/_data/partials/hyde-pages-api/hyde-kernel-string-methods.md @@ -1,7 +1,7 @@
- + #### `makeTitle()` @@ -11,6 +11,14 @@ No description provided. Hyde::makeTitle(string $value): string ``` +#### `makeSlug()` + +No description provided. + +```php +Hyde::makeSlug(string $value): string +``` + #### `normalizeNewlines()` No description provided. diff --git a/docs/architecture-concepts/the-hydekernel.md b/docs/architecture-concepts/the-hydekernel.md index 2c266bbecd3..9c6fa238b85 100644 --- a/docs/architecture-concepts/the-hydekernel.md +++ b/docs/architecture-concepts/the-hydekernel.md @@ -140,7 +140,7 @@ Hyde::routes(): Hyde\Foundation\Kernel\RouteCollection
- + #### `makeTitle()` @@ -150,6 +150,14 @@ No description provided. Hyde::makeTitle(string $value): string ``` +#### `makeSlug()` + +No description provided. + +```php +Hyde::makeSlug(string $value): string +``` + #### `normalizeNewlines()` No description provided. diff --git a/monorepo/HydeStan/HydeStan.php b/monorepo/HydeStan/HydeStan.php index 279fed01ce4..96d28ff3a9c 100644 --- a/monorepo/HydeStan/HydeStan.php +++ b/monorepo/HydeStan/HydeStan.php @@ -15,6 +15,7 @@ final class HydeStan private const FILE_ANALYSERS = [ NoFixMeAnalyser::class, UnImportedFunctionAnalyser::class, + NoGlobBraceAnalyser::class, ]; private const TEST_FILE_ANALYSERS = [ @@ -370,6 +371,27 @@ public function run(string $file, string $contents): void } } +class NoGlobBraceAnalyser extends FileAnalyser +{ + public function run(string $file, string $contents): void + { + $lines = explode("\n", $contents); + + foreach ($lines as $lineNumber => $line) { + AnalysisStatisticsContainer::analysedExpression(); + + if (str_contains($line, 'GLOB_BRACE')) { + $this->fail(sprintf('Usage of `GLOB_BRACE` found in %s at line %d. This feature is not supported on all systems and should be avoided.', + realpath(BASE_PATH.'/'.$file), + $lineNumber + 1 + )); + + HydeStan::addActionsMessage('error', $file, $lineNumber + 1, 'HydeStan: NoGlobBraceError', '`GLOB_BRACE` is not supported on all systems. Consider refactoring to avoid it.'); + } + } + } +} + class NoTestReferenceAnalyser extends LineAnalyser { public function run(string $file, int $lineNumber, string $line): void diff --git a/packages/framework/.github/bin/pick.php b/packages/framework/.github/bin/pick.php new file mode 100644 index 00000000000..c0303b09101 --- /dev/null +++ b/packages/framework/.github/bin/pick.php @@ -0,0 +1,58 @@ +#!/usr/bin/env php + [--pretend]\n"; + echo "\033[33mExample:\033[0m php bin/pick.php abc123 feature-branch\n"; + exit(1); +} + +// Get arguments +$hash = $argv[1]; +$branch = $argv[2]; +$pretend = ($argv[3] ?? false) === '--pretend'; + +// Get the commit message +exec("git show $hash --pretty=format:\"%s%n%b\" -s", $output, $returnCode); + +if ($returnCode === 0 && !empty($output)) { + $commitMessage = implode("\n", $output); + + // Check if this matches the subrepo sync format + if (preg_match('/^Merge pull request #(\d+).*\n(.*?)https:\/\/github\.com\/hydephp\/develop\/commit/', $commitMessage, $matches)) { + $prNumber = $matches[1]; + $title = trim($matches[2]); + $body = "Merges pull request https://github.com/hydephp/develop/pull/$prNumber"; + + $printWhenDone = "\n\033[33mSuggested PR format: (Line 1: title, Line 2: description, Line 3: command)\033[0m\n"; + $printWhenDone .= "$title\n$body\n"; + + $printWhenDone .= "\033[37mgh pr create --title \"$title\" --body \"$body\"\033[0m\n"; + } +} + +// Create new branch from master +exec(($pretend ? 'echo ' : '') . "git checkout -b $branch master", $output, $returnCode); + +if ($returnCode !== 0) { + echo "\033[31mError creating new branch: $branch\033[0m\n"; + exit(1); +} + +// Cherry-pick the commit +exec(($pretend ? 'echo ' : '') . "git cherry-pick $hash", $output, $returnCode); + +if ($returnCode !== 0) { + echo "\033[31mError cherry-picking commit: $hash\033[0m\n"; + exit(1); +} + +echo "\033[32mSuccessfully created branch '$branch' and cherry-picked commit '$hash'\033[0m\n"; + +if (isset($printWhenDone)) { + echo $printWhenDone; +} diff --git a/packages/framework/.github/workflows/run-tests.yml b/packages/framework/.github/workflows/run-tests.yml index 76b1488c7fe..c78208af9be 100644 --- a/packages/framework/.github/workflows/run-tests.yml +++ b/packages/framework/.github/workflows/run-tests.yml @@ -26,46 +26,41 @@ jobs: - name: Install Hyde shell: bash - run: | - if [ "${{ github.ref }}" == "refs/heads/master" ]; then - git clone -b master https://github.com/hydephp/hyde.git - else - git clone -b develop https://github.com/hydephp/hyde.git - fi + run: git clone -b master https://github.com/hydephp/develop.git --depth 1 runner - - name: Copy over framework source code + - name: Copy over framework code shell: bash run: | - mkdir -p ./hyde/packages/hyde/framework/src + rm -rf ./runner/packages/framework/src + rm -rf ./runner/packages/framework/tests + mkdir -p ./runner/packages/framework/src + mkdir -p ./runner/packages/framework/tests - # Since we can't use rsync on Windows, we need to copy the files to a temporary directory and then copy them back if [ "${{ matrix.os }}" == "windows-latest" ]; then + # For Windows, copy to temp then back to preserve structure mkdir ../temp cp -r ./ ../temp - rm -rf ../temp/hyde - cp -r ../temp/. ./hyde/packages/hyde/framework/src + rm -rf ../temp/runner + cp -r ../temp/src/. ./runner/packages/framework/src + cp -r ../temp/tests/. ./runner/packages/framework/tests else - rsync -a --exclude=hyde ./. ./hyde/packages/hyde/framework/src + # For Unix systems, use rsync + rsync -a --exclude=runner ./src/. ./runner/packages/framework/src + rsync -a --exclude=runner ./tests/. ./runner/packages/framework/tests fi - - name: Update composer.json to load framework from local source - run: | - cd hyde - composer config repositories.framework path ./packages/hyde/framework - composer require hyde/testing:dev-master hyde/framework:dev-develop - - - name: Download test runner configuration - run: cd hyde && curl https://raw.githubusercontent.com/hydephp/develop/master/packages/hyde/phpunit.xml.dist -o phpunit.xml.dist + - name: Install dependencies + run: cd runner && composer install - name: Set environment to testing - run: cd hyde && echo "ENV=testing" > .env + run: cd runner && echo "ENV=testing" > .env - name: Execute tests (Unit and Feature tests) via PHPUnit/Pest - run: cd hyde && vendor/bin/pest --log-junit report.xml + run: cd runner && vendor/bin/pest --log-junit report.xml env: ENV: testing - name: Ping statistics server with test results run: | - cd hyde + cd runner curl https://raw.githubusercontent.com/hydephp/develop/6e9d17f31879f4ccda13a3fec4029c9663bccec0/monorepo/scripts/ping-openanalytics-testrunner.php -o ping.php php ping.php "Framework CI Matrix" ${{ secrets.OPENANALYTICS_TOKEN }} diff --git a/packages/framework/src/Facades/Filesystem.php b/packages/framework/src/Facades/Filesystem.php index 6b8753a639b..5523f658c5f 100644 --- a/packages/framework/src/Facades/Filesystem.php +++ b/packages/framework/src/Facades/Filesystem.php @@ -69,6 +69,21 @@ public static function smartGlob(string $pattern, int $flags = 0): Collection return self::kernel()->filesystem()->smartGlob($pattern, $flags); } + /** + * Find files in the project's directory, with optional filtering by extension and recursion. + * + * The returned collection will be a list of paths relative to the project root. + * + * @param string $directory + * @param string|array|false $matchExtensions The file extension(s) to match, or false to match all files. + * @param bool $recursive Whether to search recursively or not. + * @return \Illuminate\Support\Collection + */ + public static function findFiles(string $directory, string|array|false $matchExtensions = false, bool $recursive = false): Collection + { + return self::kernel()->filesystem()->findFiles($directory, $matchExtensions, $recursive); + } + /** * Touch one or more files in the project's directory. * diff --git a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php index 5fb387be696..ce1d7a1ab31 100644 --- a/packages/framework/src/Foundation/Concerns/HasMediaFiles.php +++ b/packages/framework/src/Foundation/Concerns/HasMediaFiles.php @@ -10,9 +10,7 @@ use Hyde\Support\Filesystem\MediaFile; use Illuminate\Support\Collection; -use function implode; use function collect; -use function sprintf; /** * @internal Single-use trait for the Filesystem class. @@ -45,13 +43,8 @@ protected static function discoverMediaFiles(): Collection protected static function getMediaFiles(): array { - return Filesystem::glob(static::getMediaGlobPattern(), GLOB_BRACE) ?: []; - } - - protected static function getMediaGlobPattern(): string - { - return sprintf(Hyde::getMediaDirectory().'/{*,**/*,**/*/*}.{%s}', implode(',', - Config::getArray('hyde.media_extensions', MediaFile::EXTENSIONS) - )); + return Filesystem::findFiles(Hyde::getMediaDirectory(), + Config::getArray('hyde.media_extensions', MediaFile::EXTENSIONS), recursive: true + )->all(); } } diff --git a/packages/framework/src/Foundation/Concerns/ImplementsStringHelpers.php b/packages/framework/src/Foundation/Concerns/ImplementsStringHelpers.php index a5910e8bf71..910960488a2 100644 --- a/packages/framework/src/Foundation/Concerns/ImplementsStringHelpers.php +++ b/packages/framework/src/Foundation/Concerns/ImplementsStringHelpers.php @@ -38,6 +38,19 @@ public static function makeTitle(string $value): string )); } + public static function makeSlug(string $value): string + { + // Expand camelCase and PascalCase to separate words + $value = preg_replace('/([a-z])([A-Z])/', '$1 $2', $value); + + // Transliterate international characters to ASCII + $value = Str::transliterate($value); + + // Todo: In v2.0 we will use the following dictionary: ['@' => 'at', '&' => 'and'] + + return Str::slug($value); + } + public static function normalizeNewlines(string $string): string { return str_replace("\r\n", "\n", $string); diff --git a/packages/framework/src/Foundation/HydeKernel.php b/packages/framework/src/Foundation/HydeKernel.php index 14c3047afad..0bb188c7273 100644 --- a/packages/framework/src/Foundation/HydeKernel.php +++ b/packages/framework/src/Foundation/HydeKernel.php @@ -50,7 +50,7 @@ class HydeKernel implements SerializableContract use Serializable; use Macroable; - final public const VERSION = '1.7.3'; + final public const VERSION = '1.7.4'; protected static self $instance; diff --git a/packages/framework/src/Foundation/Kernel/FileCollection.php b/packages/framework/src/Foundation/Kernel/FileCollection.php index b52355ddef8..49c67e49aa3 100644 --- a/packages/framework/src/Foundation/Kernel/FileCollection.php +++ b/packages/framework/src/Foundation/Kernel/FileCollection.php @@ -59,7 +59,7 @@ protected function runExtensionHandlers(): void protected function discoverFilesFor(string $pageClass): void { // Scan the source directory, and directories therein, for files that match the model's file extension. - foreach (Filesystem::smartGlob($pageClass::sourcePath('{*,**/*}'), GLOB_BRACE) as $path) { + foreach (Filesystem::findFiles($pageClass::sourceDirectory(), $pageClass::fileExtension(), true) as $path) { if (! str_starts_with(basename((string) $path), '_')) { $this->addFile(SourceFile::make($path, $pageClass)); } diff --git a/packages/framework/src/Foundation/Kernel/Filesystem.php b/packages/framework/src/Foundation/Kernel/Filesystem.php index 345f6caa524..1c5c6730776 100644 --- a/packages/framework/src/Foundation/Kernel/Filesystem.php +++ b/packages/framework/src/Foundation/Kernel/Filesystem.php @@ -9,6 +9,7 @@ use Hyde\Foundation\PharSupport; use Hyde\Foundation\Concerns\HasMediaFiles; use Illuminate\Support\Collection; +use Hyde\Framework\Actions\Internal\FileFinder; use function collect; use function Hyde\normalize_slashes; @@ -160,4 +161,16 @@ public function smartGlob(string $pattern, int $flags = 0): Collection return $files->map(fn (string $path): string => $this->pathToRelative($path)); } + + /** + * @param string|array|false $matchExtensions + * @return \Illuminate\Support\Collection + */ + public function findFiles(string $directory, string|array|false $matchExtensions = false, bool $recursive = false): Collection + { + /** @var \Hyde\Framework\Actions\Internal\FileFinder $finder */ + $finder = app(FileFinder::class); + + return $finder->handle($directory, $matchExtensions, $recursive); + } } diff --git a/packages/framework/src/Framework/Actions/CreatesNewMarkdownPostFile.php b/packages/framework/src/Framework/Actions/CreatesNewMarkdownPostFile.php index 3f1f4ea9625..f524ded7c5b 100644 --- a/packages/framework/src/Framework/Actions/CreatesNewMarkdownPostFile.php +++ b/packages/framework/src/Framework/Actions/CreatesNewMarkdownPostFile.php @@ -6,9 +6,9 @@ use Hyde\Framework\Exceptions\FileConflictException; use Hyde\Facades\Filesystem; +use Hyde\Hyde; use Hyde\Pages\MarkdownPost; use Illuminate\Support\Carbon; -use Illuminate\Support\Str; /** * Offloads logic for the make:post command. @@ -48,7 +48,7 @@ public function __construct(string $title, ?string $description, ?string $catego $this->customContent = $customContent; $this->date = Carbon::make($date ?? Carbon::now())->format('Y-m-d H:i'); - $this->identifier = Str::slug($title); + $this->identifier = Hyde::makeSlug($title); } /** diff --git a/packages/framework/src/Framework/Actions/CreatesNewPageSourceFile.php b/packages/framework/src/Framework/Actions/CreatesNewPageSourceFile.php index ccb0fb6f809..aa9355a6f46 100644 --- a/packages/framework/src/Framework/Actions/CreatesNewPageSourceFile.php +++ b/packages/framework/src/Framework/Actions/CreatesNewPageSourceFile.php @@ -81,7 +81,7 @@ protected function fileName(string $title): string } // And return a slug made from just the title without the subdirectory - return Str::slug(basename($title)); + return Hyde::makeSlug(basename($title)); } protected function normalizeSubdirectory(string $title): string diff --git a/packages/framework/src/Framework/Actions/Internal/FileFinder.php b/packages/framework/src/Framework/Actions/Internal/FileFinder.php new file mode 100644 index 00000000000..7647b23a2fd --- /dev/null +++ b/packages/framework/src/Framework/Actions/Internal/FileFinder.php @@ -0,0 +1,66 @@ +|string|false $matchExtensions + * @return \Illuminate\Support\Collection + */ + public static function handle(string $directory, array|string|false $matchExtensions = false, bool $recursive = false): Collection + { + if (! Filesystem::isDirectory($directory)) { + return collect(); + } + + $finder = Finder::create()->files()->in(Hyde::path($directory)); + + if ($recursive === false) { + $finder->depth('== 0'); + } + + if ($matchExtensions !== false) { + $finder->name(static::buildFileExtensionPattern((array) $matchExtensions)); + } + + return collect($finder)->map(function (SplFileInfo $file): string { + return Hyde::pathToRelative($file->getPathname()); + })->sort()->values(); + } + + /** @param array $extensions */ + protected static function buildFileExtensionPattern(array $extensions): string + { + $extensions = self::expandCommaSeparatedValues($extensions); + + return '/\.('.self::normalizeExtensionForRegexPattern($extensions).')$/i'; + } + + /** @param array $extensions */ + private static function expandCommaSeparatedValues(array $extensions): array + { + return array_merge(...array_map(function (string $item): array { + return array_map(fn (string $item): string => trim($item), explode(',', $item)); + }, $extensions)); + } + + /** @param array $extensions */ + private static function normalizeExtensionForRegexPattern(array $extensions): string + { + return implode('|', array_map(function (string $extension): string { + return preg_quote(ltrim($extension, '.'), '/'); + }, $extensions)); + } +} diff --git a/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php b/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php index ba64f24ee76..d4c279ddd33 100644 --- a/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php +++ b/packages/framework/src/Framework/Actions/PreBuildTasks/CleanSiteDirectory.php @@ -11,7 +11,6 @@ use Hyde\Framework\Features\BuildTasks\PreBuildTask; use function basename; -use function glob; use function in_array; use function sprintf; @@ -22,7 +21,7 @@ class CleanSiteDirectory extends PreBuildTask public function handle(): void { if ($this->isItSafeToCleanOutputDirectory()) { - Filesystem::unlink(glob(Hyde::sitePath('*.{html,json}'), GLOB_BRACE)); + Filesystem::unlink(Filesystem::findFiles(Hyde::sitePath(), ['html', 'json'])->all()); Filesystem::cleanDirectory(MediaFile::outputPath()); } } diff --git a/packages/framework/src/Support/DataCollection.php b/packages/framework/src/Support/DataCollection.php index d7dee9677b4..bc6f38bd67b 100644 --- a/packages/framework/src/Support/DataCollection.php +++ b/packages/framework/src/Support/DataCollection.php @@ -15,8 +15,7 @@ use Symfony\Component\Yaml\Exception\ParseException; use function blank; -use function implode; -use function sprintf; +use function Hyde\path_join; use function Hyde\unslash; use function json_decode; use function json_last_error_msg; @@ -106,9 +105,7 @@ protected static function discover(string $name, array|string $extensions, calla */ protected static function findFiles(string $name, array|string $extensions): Collection { - return Filesystem::smartGlob(sprintf('%s/%s/*.{%s}', - static::$sourceDirectory, $name, implode(',', (array) $extensions) - ), GLOB_BRACE); + return Filesystem::findFiles(path_join(static::$sourceDirectory, $name), $extensions); } protected static function makeIdentifier(string $path): string diff --git a/packages/framework/src/Support/Filesystem/MediaFile.php b/packages/framework/src/Support/Filesystem/MediaFile.php index 82eadc79b9b..888cd91ae39 100644 --- a/packages/framework/src/Support/Filesystem/MediaFile.php +++ b/packages/framework/src/Support/Filesystem/MediaFile.php @@ -4,10 +4,10 @@ namespace Hyde\Support\Filesystem; +use Hyde\Facades\Filesystem; use Hyde\Hyde; use Stringable; use Hyde\Facades\Config; -use Hyde\Facades\Filesystem; use Illuminate\Support\Collection; use Hyde\Framework\Exceptions\FileNotFoundException; use Illuminate\Support\Str; @@ -25,7 +25,7 @@ class MediaFile extends ProjectFile implements Stringable { /** @var array The default extensions for media types */ - final public const EXTENSIONS = ['png', 'svg', 'jpg', 'jpeg', 'gif', 'ico', 'css', 'js']; + final public const EXTENSIONS = ['png', 'svg', 'jpg', 'jpeg', 'webp', 'gif', 'ico', 'css', 'js']; protected readonly int $length; protected readonly string $mimeType; diff --git a/packages/framework/tests/Feature/FileCollectionTest.php b/packages/framework/tests/Feature/FileCollectionTest.php index 2bc4f1d66a4..cb0d5ba85d9 100644 --- a/packages/framework/tests/Feature/FileCollectionTest.php +++ b/packages/framework/tests/Feature/FileCollectionTest.php @@ -106,4 +106,20 @@ public function testDocumentationPagesAreDiscovered() $this->assertArrayHasKey('_docs/foo.md', $collection->toArray()); $this->assertEquals(new SourceFile('_docs/foo.md', DocumentationPage::class), $collection->get('_docs/foo.md')); } + + public function testDiscoverFilesForRecursivelyDiscoversFilesInSubdirectories() + { + $this->file('_pages/foo.md'); + $this->file('_pages/foo/bar.md'); + $this->file('_pages/foo/bar/baz.md'); + $collection = FileCollection::init(Hyde::getInstance())->boot(); + + $this->assertArrayHasKey('_pages/foo.md', $collection->toArray()); + $this->assertArrayHasKey('_pages/foo/bar.md', $collection->toArray()); + $this->assertArrayHasKey('_pages/foo/bar/baz.md', $collection->toArray()); + + $this->assertEquals(new SourceFile('_pages/foo.md', MarkdownPage::class), $collection->get('_pages/foo.md')); + $this->assertEquals(new SourceFile('_pages/foo/bar.md', MarkdownPage::class), $collection->get('_pages/foo/bar.md')); + $this->assertEquals(new SourceFile('_pages/foo/bar/baz.md', MarkdownPage::class), $collection->get('_pages/foo/bar/baz.md')); + } } diff --git a/packages/framework/tests/Feature/Foundation/FilesystemTest.php b/packages/framework/tests/Feature/Foundation/FilesystemTest.php index bc4ccfcb7e3..0e5e18d6313 100644 --- a/packages/framework/tests/Feature/Foundation/FilesystemTest.php +++ b/packages/framework/tests/Feature/Foundation/FilesystemTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Hyde\Foundation\Kernel\Filesystem; use Hyde\Foundation\PharSupport; +use Hyde\Framework\Actions\Internal\FileFinder; use Hyde\Hyde; use Hyde\Pages\BladePage; use Hyde\Pages\DocumentationPage; @@ -15,6 +16,7 @@ use Hyde\Pages\MarkdownPage; use Hyde\Pages\MarkdownPost; use Hyde\Support\Filesystem\MediaFile; +use Hyde\Testing\CreatesTemporaryFiles; use Hyde\Testing\UnitTestCase; use function Hyde\normalize_slashes; @@ -23,9 +25,12 @@ * @covers \Hyde\Foundation\HydeKernel * @covers \Hyde\Foundation\Kernel\Filesystem * @covers \Hyde\Foundation\Concerns\HasMediaFiles + * @covers \Hyde\Facades\Filesystem */ class FilesystemTest extends UnitTestCase { + use CreatesTemporaryFiles; + protected string $originalBasePath; protected Filesystem $filesystem; @@ -382,4 +387,80 @@ public function testAssetsMethodReturnsAssetCollectionSingleton() { $this->assertSame($this->filesystem->assets(), $this->filesystem->assets()); } + + public function testFindFileMethodFindsFilesInDirectory() + { + $this->files(['directory/apple.md', 'directory/banana.md', 'directory/cherry.md']); + $files = $this->filesystem->findFiles('directory'); + + $this->assertCount(3, $files); + $this->assertContains('directory/apple.md', $files); + $this->assertContains('directory/banana.md', $files); + $this->assertContains('directory/cherry.md', $files); + + $this->cleanUpFilesystem(); + } + + public function testFindFileMethodTypes() + { + $this->file('directory/apple.md'); + $files = $this->filesystem->findFiles('directory'); + + $this->assertInstanceOf(Collection::class, $files); + $this->assertContainsOnly('int', $files->keys()); + $this->assertContainsOnly('string', $files->all()); + $this->assertSame('directory/apple.md', $files->first()); + + $this->cleanUpFilesystem(); + } + + public function testFindFileMethodTypesWithArguments() + { + $this->file('directory/apple.md'); + + $this->assertInstanceOf(Collection::class, $this->filesystem->findFiles('directory', false, false)); + $this->assertInstanceOf(Collection::class, $this->filesystem->findFiles('directory', 'md', false)); + $this->assertInstanceOf(Collection::class, $this->filesystem->findFiles('directory', false, true)); + $this->assertInstanceOf(Collection::class, $this->filesystem->findFiles('directory', 'md', true)); + + $this->cleanUpFilesystem(); + } + + public function testFindFilesFromFilesystemFacade() + { + $this->files(['directory/apple.md', 'directory/banana.md', 'directory/cherry.md']); + $files = \Hyde\Facades\Filesystem::findFiles('directory'); + + $this->assertSame(['directory/apple.md', 'directory/banana.md', 'directory/cherry.md'], $files->sort()->values()->all()); + + $this->cleanUpFilesystem(); + } + + public function testFindFilesFromFilesystemFacadeWithArguments() + { + $this->files(['directory/apple.md', 'directory/banana.txt', 'directory/cherry.blade.php', 'directory/nested/dates.md']); + + $files = \Hyde\Facades\Filesystem::findFiles('directory', 'md'); + $this->assertSame(['directory/apple.md'], $files->all()); + + $files = \Hyde\Facades\Filesystem::findFiles('directory', false, true); + $this->assertSame(['directory/apple.md', 'directory/banana.txt', 'directory/cherry.blade.php', 'directory/nested/dates.md'], $files->sort()->values()->all()); + + $this->cleanUpFilesystem(); + } + + public function testCanSwapOutFileFinder() + { + app()->bind(FileFinder::class, function () { + return new class + { + public static function handle(): Collection + { + return collect(['mocked']); + } + }; + }); + + $this->assertSame(['mocked'], \Hyde\Facades\Filesystem::findFiles('directory')->toArray()); + } } diff --git a/packages/framework/tests/Feature/HydeKernelTest.php b/packages/framework/tests/Feature/HydeKernelTest.php index bc4591539dc..c3344220706 100644 --- a/packages/framework/tests/Feature/HydeKernelTest.php +++ b/packages/framework/tests/Feature/HydeKernelTest.php @@ -37,6 +37,7 @@ * @covers \Hyde\Hyde * * @see \Hyde\Framework\Testing\Unit\HydeHelperFacadeMakeTitleTest + * @see \Hyde\Framework\Testing\Unit\HydeHelperFacadeMakeSlugTest * @see \Hyde\Framework\Testing\Feature\HydeExtensionFeatureTest */ class HydeKernelTest extends TestCase @@ -112,6 +113,11 @@ public function testMakeTitleHelperReturnsTitleFromPageSlug() $this->assertSame('Foo Bar', Hyde::makeTitle('foo-bar')); } + public function testMakeSlugHelperReturnsSlugFromTitle() + { + $this->assertSame('foo-bar', Hyde::makeSlug('Foo Bar')); + } + public function testNormalizeNewlinesReplacesCarriageReturnsWithUnixEndings() { $this->assertSame("foo\nbar\nbaz", Hyde::normalizeNewlines("foo\nbar\r\nbaz")); diff --git a/packages/framework/tests/Feature/InternationalizationTest.php b/packages/framework/tests/Feature/InternationalizationTest.php new file mode 100644 index 00000000000..04e6d6fa861 --- /dev/null +++ b/packages/framework/tests/Feature/InternationalizationTest.php @@ -0,0 +1,117 @@ +save(); + + $this->assertSame("_posts/$expectedSlug.md", $path); + $this->assertSame($expectedSlug, $creator->getIdentifier()); + $this->assertSame($creator->getIdentifier(), Hyde::makeSlug($title)); + $this->assertFileExists($path); + + $contents = file_get_contents($path); + + if (str_contains($title, ' ')) { + $expectedTitle = "'$expectedTitle'"; + } + + if (str_contains($description, ' ')) { + $description = "'$description'"; + } + + $this->assertStringContainsString("title: $expectedTitle", $contents); + $this->assertSame(<< $title, + 'description' => $description, + 'category' => 'blog', + 'author' => 'default', + 'date' => '2024-12-22 10:45', + ]); + + $path = StaticPageBuilder::handle($page); + + $this->assertSame(Hyde::path("_site/posts/$expectedSlug.html"), $path); + $this->assertFileExists($path); + + $contents = file_get_contents($path); + + $this->assertStringContainsString("HydePHP - $expectedTitle", $contents); + $this->assertStringContainsString("

$expectedTitle

", $contents); + $this->assertStringContainsString("", $contents); + + Filesystem::unlink($path); + } + + public static function internationalCharacterSetsProvider(): array + { + return [ + 'Chinese (Simplified)' => [ + '你好世界', + '简短描述', + 'ni-hao-shi-jie', + '你好世界', + ], + 'Japanese' => [ + 'こんにちは世界', + '短い説明', + 'konnichihashi-jie', + 'こんにちは世界', + ], + 'Korean' => [ + '안녕하세요 세계', + '짧은 설명', + 'annyeonghaseyo-segye', + '안녕하세요 세계', + ], + ]; + } +} diff --git a/packages/framework/tests/Unit/DataCollectionUnitTest.php b/packages/framework/tests/Unit/DataCollectionUnitTest.php index 921d7e2c836..8ebff95cd7d 100644 --- a/packages/framework/tests/Unit/DataCollectionUnitTest.php +++ b/packages/framework/tests/Unit/DataCollectionUnitTest.php @@ -4,13 +4,13 @@ namespace Hyde\Framework\Testing\Unit; +use Hyde\Framework\Actions\Internal\FileFinder; +use Hyde\Testing\UnitTestCase; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Collection; use Mockery; -use Hyde\Hyde; use Illuminate\Support\Str; -use Hyde\Testing\UnitTestCase; use Hyde\Support\DataCollection; -use Illuminate\Support\Collection; -use Illuminate\Filesystem\Filesystem; use Hyde\Markdown\Models\FrontMatter; use Hyde\Markdown\Models\MarkdownDocument; use Hyde\Framework\Exceptions\ParseException; @@ -54,18 +54,9 @@ public function testCanConvertCollectionToJson() $this->assertSame('[]', (new DataCollection())->toJson()); } - public function testFindMarkdownFilesCallsProperGlobPattern() - { - $this->mockFilesystemFacade(['shouldReceiveGlob' => true]); - - DataCollection::markdown('foo')->keys()->toArray(); - - $this->verifyMockeryExpectations(); - } - public function testFindMarkdownFilesWithNoFiles() { - $this->mockFilesystemFacade(); + $this->mockFileFinder([]); $this->assertSame([], DataCollection::markdown('foo')->keys()->toArray()); @@ -74,7 +65,7 @@ public function testFindMarkdownFilesWithNoFiles() public function testFindMarkdownFilesWithFiles() { - $this->mockFilesystemFacade(['glob' => ['bar.md']]); + $this->mockFileFinder(['bar.md']); $this->assertSame(['bar.md'], DataCollection::markdown('foo')->keys()->toArray()); @@ -486,26 +477,26 @@ public function testJsonMethodThrowsExceptionForOtherReasonsThanSyntaxErrorWithC MockableDataCollection::json('foo'); } - protected function mockFilesystemFacade(array $config = []): void + protected function mockFileFinder(array $files): void { - $defaults = [ - 'exists' => true, - 'glob' => [], - 'get' => 'foo', - ]; + $filesystem = Mockery::mock(Filesystem::class); + $filesystem->shouldReceive('exists')->andReturn(true); + $filesystem->shouldReceive('get')->andReturn('foo'); - $config = array_merge($defaults, $config); + app()->instance(Filesystem::class, $filesystem); - $filesystem = Mockery::mock(Filesystem::class, $config); + $finder = Mockery::mock(FileFinder::class); + $finder->shouldReceive('handle')->andReturn(collect($files)); - if (isset($config['shouldReceiveGlob'])) { - $filesystem->shouldReceive('glob') - ->with(Hyde::path('resources/collections/foo/*.{md}'), GLOB_BRACE) - ->once() - ->andReturn($config['glob']); - } + app()->instance(FileFinder::class, $finder); + } - app()->instance(Filesystem::class, $filesystem); + protected function verifyMockeryExpectations(): void + { + parent::verifyMockeryExpectations(); + + app()->forgetInstance(Filesystem::class); + app()->forgetInstance(FileFinder::class); } protected function assertMarkdownCollectionStructure(array $expected, DataCollection $collection): void diff --git a/packages/framework/tests/Unit/FileFinderTest.php b/packages/framework/tests/Unit/FileFinderTest.php new file mode 100644 index 00000000000..fadf155decc --- /dev/null +++ b/packages/framework/tests/Unit/FileFinderTest.php @@ -0,0 +1,276 @@ +files(['directory/apple.md', 'directory/banana.md', 'directory/cherry.md']); + $this->assertSameArray(['apple.md', 'banana.md', 'cherry.md'], 'directory'); + } + + public function testFindFilesWithMixedExtensions() + { + $this->files(['directory/apple.md', 'directory/banana.txt', 'directory/cherry.blade.php']); + $this->assertSameArray(['apple.md', 'banana.txt', 'cherry.blade.php'], 'directory'); + } + + public function testFindFilesWithExtension() + { + $this->files(['directory/apple.md', 'directory/banana.md', 'directory/cherry.md']); + $this->assertSameArray(['apple.md', 'banana.md', 'cherry.md'], 'directory', 'md'); + } + + public function testFindFilesWithMixedExtensionsReturnsOnlySpecifiedExtension() + { + $this->files(['directory/apple.md', 'directory/banana.txt', 'directory/cherry.blade.php']); + $this->assertSameArray(['apple.md'], 'directory', 'md'); + } + + public function testFindFilesWithRecursive() + { + $this->files(['directory/apple.md', 'directory/banana.md', 'directory/cherry.md', 'directory/nested/dates.md']); + $this->assertSameArray(['apple.md', 'banana.md', 'cherry.md', 'nested/dates.md'], 'directory', false, true); + } + + public function testFindFilesWithDeeplyRecursiveFiles() + { + $this->files(['directory/apple.md', 'directory/nested/banana.md', 'directory/nested/deeply/cherry.md']); + $this->assertSameArray(['apple.md', 'nested/banana.md', 'nested/deeply/cherry.md'], 'directory', false, true); + } + + public function testFindFilesWithVeryDeeplyRecursiveFiles() + { + $this->files(['directory/apple.md', 'directory/nested/banana.md', 'directory/nested/deeply/cherry.md', 'directory/nested/very/very/deeply/dates.md', 'directory/nested/very/very/excessively/deeply/elderberries.md']); + $this->assertSameArray(['apple.md', 'nested/banana.md', 'nested/deeply/cherry.md', 'nested/very/very/deeply/dates.md', 'nested/very/very/excessively/deeply/elderberries.md'], 'directory', false, true); + } + + public function testFindFilesIgnoresNestedFilesIfNotRecursive() + { + $this->files(['directory/apple.md', 'directory/nested/banana.md', 'directory/nested/deeply/cherry.md']); + $this->assertSameArray(['apple.md'], 'directory'); + } + + public function testFindFilesReturnsCorrectFilesWhenUsingNestedSubdirectoriesOfDifferentExtensions() + { + $this->files(['directory/apple.md', 'directory/nested/banana.md', 'directory/nested/deeply/cherry.blade.php']); + $this->assertSameArray(['apple.md', 'nested/banana.md'], 'directory', 'md', true); + } + + public function testFindFilesWithFilesHavingNoExtensions() + { + $this->files(['directory/file', 'directory/another_file']); + $this->assertSameArray(['file', 'another_file'], 'directory'); + } + + public function testFindFilesWithSpecialCharactersInNames() + { + $this->files(['directory/file-with-dash.md', 'directory/another_file.txt', 'directory/special@char!.blade.php']); + $this->assertSameArray(['file-with-dash.md', 'another_file.txt', 'special@char!.blade.php'], 'directory'); + } + + public function testFindFilesWithSpecialPrefixes() + { + $this->files(['directory/_file.md', 'directory/-another_file.txt', 'directory/~special_file.blade.php']); + $this->assertSameArray(['_file.md', '-another_file.txt', '~special_file.blade.php'], 'directory'); + } + + public function testFindFilesWithHiddenFiles() + { + $this->files(['directory/.hidden_file', 'directory/.another_hidden.md', 'directory/visible_file.md']); + $this->assertSameArray(['visible_file.md'], 'directory'); + } + + public function testFindFilesWithRecursiveAndHiddenFiles() + { + $this->files(['directory/.hidden_file', 'directory/nested/.another_hidden.md', 'directory/nested/visible_file.md']); + $this->assertSameArray(['nested/visible_file.md'], 'directory', false, true); + } + + public function testFindFilesWithEmptyExtensionFilter() + { + $this->files(['directory/file.md', 'directory/another_file.txt']); + $this->assertSameArray([], 'directory', ''); + } + + public function testFindFilesWithCaseInsensitiveExtensions() + { + $this->files(['directory/file.MD', 'directory/another_file.md', 'directory/ignored.TXT']); + $this->assertSameArray(['file.MD', 'another_file.md'], 'directory', 'md'); + } + + public function testFindFilesWithCaseInsensitiveFilenames() + { + $this->files(['directory/file.md', 'directory/anotherFile.md', 'directory/ANOTHER_FILE.md']); + $this->assertSameArray(['file.md', 'anotherFile.md', 'ANOTHER_FILE.md'], 'directory'); + } + + public function testFindFilesWithCaseInsensitiveExtensionFilter() + { + $this->files(['directory/file.MD', 'directory/another_file.md', 'directory/ignored.TXT']); + $this->assertSameArray(['file.MD', 'another_file.md'], 'directory', 'MD'); + } + + public function testFindFilesWithLeadingDotInFileExtension() + { + $this->files(['directory/file.md', 'directory/another_file.md', 'directory/ignored.txt']); + $this->assertSameArray(['file.md', 'another_file.md'], 'directory', 'md'); + $this->assertSameArray(['file.md', 'another_file.md'], 'directory', '.md'); + } + + public function testFindFilesHandlesLargeNumberOfFiles() + { + $this->files(array_map(fn ($i) => "directory/file$i.md", range(1, 100))); + $this->assertSameArray(array_map(fn ($i) => "file$i.md", range(1, 100)), 'directory'); + } + + public function testFindFilesWithEmptyDirectory() + { + $this->directory('directory'); + $this->assertSameArray([], 'directory'); + } + + public function testFindFilesWithNonExistentDirectory() + { + $this->assertSameArray([], 'nonexistent-directory'); + } + + public function testFindFilesWithMultipleExtensions() + { + $this->files(['directory/file1.md', 'directory/file2.txt', 'directory/file3.blade.php']); + $this->assertSameArray(['file1.md', 'file2.txt'], 'directory', ['md', 'txt']); + } + + public function testFindFilesWithMultipleExtensionsButOnlyOneMatches() + { + $this->files(['directory/file1.md', 'directory/file2.blade.php', 'directory/file3.blade.php']); + $this->assertSameArray(['file1.md'], 'directory', ['md', 'txt']); + } + + public function testFindFilesWithMultipleExtensionsCaseInsensitive() + { + $this->files(['directory/file1.MD', 'directory/file2.TXT', 'directory/file3.blade.PHP']); + $this->assertSameArray(['file1.MD', 'file2.TXT'], 'directory', ['md', 'txt']); + } + + public function testFindFilesWithEmptyArrayExtensions() + { + $this->files(['directory/file1.md', 'directory/file2.txt']); + $this->assertSameArray([], 'directory', []); + } + + public function testFindFilesWithMixedExtensionsAndRecursion() + { + $this->files(['directory/file1.md', 'directory/nested/file2.txt', 'directory/nested/deep/file3.blade.php']); + $this->assertSameArray(['file1.md', 'nested/file2.txt'], 'directory', ['md', 'txt'], true); + } + + public function testFindFilesWithMixedExtensionsNoRecursion() + { + $this->files(['directory/file1.md', 'directory/nested/file2.txt']); + $this->assertSameArray(['file1.md'], 'directory', ['md', 'txt'], false); + } + + public function testFindFilesWithNoFilesMatchingAnyExtension() + { + $this->files(['directory/file1.md', 'directory/file2.txt']); + $this->assertSameArray([], 'directory', ['php', 'html']); + } + + public function testFindFilesWithRecursiveAndNoFilesMatchingAnyExtension() + { + $this->files(['directory/file1.md', 'directory/nested/file2.txt']); + $this->assertSameArray([], 'directory', ['php', 'html'], true); + } + + public function testFindFilesWithRecursiveAndSomeMatchingExtensions() + { + $this->files(['directory/file1.md', 'directory/nested/file2.txt', 'directory/nested/deep/file3.html']); + $this->assertSameArray(['file1.md', 'nested/file2.txt'], 'directory', ['md', 'txt'], true); + } + + public function testFindFilesWithOnlyDotInExtensions() + { + $this->files(['directory/file.md', 'directory/file.txt']); + $this->assertSameArray(['file.md'], 'directory', '.md'); + $this->assertSameArray(['file.txt'], 'directory', '.txt'); + } + + public function testFindFilesWithNoFilesWhenDirectoryContainsUnmatchedExtensions() + { + $this->files(['directory/file.md', 'directory/file.txt']); + $this->assertSameArray([], 'directory', 'php'); + $this->assertSameArray([], 'directory', ['php']); + } + + public function testFindFilesWithEmptyDirectoryAndMultipleExtensions() + { + $this->directory('directory'); + $this->assertSameArray([], 'directory', ['md', 'txt']); + } + + public function testFindFilesWithInvalidExtensionsThrowsNoError() + { + $this->files(['directory/file.md', 'directory/file.txt']); + $this->assertSameArray([], 'directory', ''); + $this->assertSameArray([], 'directory', ['']); + } + + public function testFindFilesWithCsvStringExtensions() + { + $this->files(['directory/file1.md', 'directory/file2.txt', 'directory/file3.jpg']); + $this->assertSameArray(['file1.md', 'file2.txt'], 'directory', 'md,txt'); + } + + public function testFindFilesWithCsvStringExtensionsAndSpaces() + { + $this->files(['directory/file1.md', 'directory/file2.txt', 'directory/file3.jpg']); + $this->assertSameArray(['file1.md', 'file2.txt'], 'directory', 'md, txt'); + } + + public function testFindFilesWithCsvStringExtensionsMixedCase() + { + $this->files(['directory/file1.MD', 'directory/file2.TXT', 'directory/file3.jpg']); + $this->assertSameArray(['file1.MD', 'file2.TXT'], 'directory', 'md,TXT'); + } + + public function testFindFilesWithCsvStringExtensionsInArray() + { + $this->files(['directory/file1.md', 'directory/file2.txt', 'directory/file3.jpg']); + $this->assertSameArray(['file1.md', 'file2.txt'], 'directory', ['md,txt']); + } + + public function testFindFilesWithCsvStringExtensionsInMixedArray() + { + $this->files(['directory/file1.md', 'directory/file2.txt', 'directory/file3.jpg']); + $this->assertSameArray(['file1.md', 'file2.txt', 'file3.jpg'], 'directory', ['md,txt', 'jpg']); + } + + protected function assertSameArray(array $expected, string $directory, string|array|false $matchExtensions = false, bool $recursive = false): void + { + $files = (new Filesystem(Hyde::getInstance()))->findFiles($directory, $matchExtensions, $recursive); + + // Compare sorted arrays because some filesystems may return files in a different order. + $this->assertSame(collect($expected)->map(fn (string $file): string => $directory.'/'.$file)->sort()->values()->all(), $files->all()); + } + + protected function tearDown(): void + { + $this->cleanUpFilesystem(); + } +} diff --git a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php index 81806cafd43..3f079d987dc 100644 --- a/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php +++ b/packages/framework/tests/Unit/Foundation/FilesystemHasMediaFilesTest.php @@ -4,6 +4,7 @@ namespace Hyde\Framework\Testing\Unit\Foundation; +use Hyde\Framework\Actions\Internal\FileFinder; use Mockery; use Hyde\Foundation\Kernel\Filesystem; use Hyde\Hyde; @@ -11,6 +12,7 @@ use Hyde\Testing\UnitTestCase; use Illuminate\Support\Collection; use Illuminate\Filesystem\Filesystem as BaseFilesystem; +use Mockery\MockInterface; /** * @covers \Hyde\Foundation\Kernel\Filesystem @@ -34,6 +36,16 @@ protected function setUp(): void app()->instance(BaseFilesystem::class, $mock); } + protected function tearDown(): void + { + $this->verifyMockeryExpectations(); + + app()->forgetInstance(BaseFilesystem::class); + app()->forgetInstance(FileFinder::class); + + Hyde::setMediaDirectory('_media'); + } + public function testAssetsMethodReturnsSameInstanceOnSubsequentCalls() { $firstCall = $this->filesystem->assets(); @@ -66,24 +78,35 @@ public function testAssetsMethodWithNestedDirectories() $this->assertTrue($assets->has('documents/report.pdf')); } - public function testGetMediaGlobPatternWithCustomMediaDirectory() + public function testUsesRecursiveFinderSearch() { - Hyde::setMediaDirectory('custom_media'); + $mock = $this->mockFileFinder(); - $pattern = $this->filesystem->getTestMediaGlobPattern(); + (new Filesystem(Hyde::getInstance()))->assets(); - $this->assertStringContainsString('custom_media/', $pattern); + $mock->shouldHaveReceived('handle')->with('_media', MediaFile::EXTENSIONS, true); + } - Hyde::setMediaDirectory('_media'); + public function testItSupportsCustomMediaDirectory() + { + Hyde::setMediaDirectory('assets'); + + $mock = $this->mockFileFinder(); + + (new Filesystem(Hyde::getInstance()))->assets(); + + $mock->shouldHaveReceived('handle')->with('assets', MediaFile::EXTENSIONS, true); } - public function testGetMediaGlobPatternWithCustomExtensions() + public function testItSupportsCustomExtensions() { self::mockConfig(['hyde.media_extensions' => ['gif', 'svg']]); - $pattern = $this->filesystem->getTestMediaGlobPattern(); + $mock = $this->mockFileFinder(); + + (new Filesystem(Hyde::getInstance()))->assets(); - $this->assertStringContainsString('{gif,svg}', $pattern); + $mock->shouldHaveReceived('handle')->with('_media', ['gif', 'svg'], true); } public function testDiscoverMediaFilesWithEmptyResult() @@ -109,6 +132,15 @@ public function testDiscoverMediaFilesWithMultipleFiles() $this->assertInstanceOf(MediaFile::class, $result->get('image.jpg')); $this->assertInstanceOf(MediaFile::class, $result->get('document.pdf')); } + + protected function mockFileFinder(): MockInterface + { + $mock = Mockery::mock(FileFinder::class); + $mock->shouldReceive('handle')->andReturn(collect()); + app()->instance(FileFinder::class, $mock); + + return $mock; + } } class TestableFilesystem extends Filesystem @@ -125,9 +157,9 @@ protected static function getMediaFiles(): array return self::$testMediaFiles; } - public function getTestMediaGlobPattern(): string + public function callGetMediaFiles(): array { - return static::getMediaGlobPattern(); + return parent::getMediaFiles(); } public function getTestDiscoverMediaFiles(): Collection diff --git a/packages/framework/tests/Unit/HydeHelperFacadeMakeSlugTest.php b/packages/framework/tests/Unit/HydeHelperFacadeMakeSlugTest.php new file mode 100644 index 00000000000..0d1279ccb9c --- /dev/null +++ b/packages/framework/tests/Unit/HydeHelperFacadeMakeSlugTest.php @@ -0,0 +1,131 @@ +assertSame('hello-world', Hyde::makeSlug('Hello World')); + } + + public function testMakeSlugHelperConvertsKebabCaseToSlug() + { + $this->assertSame('hello-world', Hyde::makeSlug('hello-world')); + } + + public function testMakeSlugHelperConvertsSnakeCaseToSlug() + { + $this->assertSame('hello-world', Hyde::makeSlug('hello_world')); + } + + public function testMakeSlugHelperConvertsCamelCaseToSlug() + { + $this->assertSame('hello-world', Hyde::makeSlug('helloWorld')); + } + + public function testMakeSlugHelperConvertsPascalCaseToSlug() + { + $this->assertSame('hello-world', Hyde::makeSlug('HelloWorld')); + } + + public function testMakeSlugHelperHandlesMultipleSpaces() + { + $this->assertSame('hello-world', Hyde::makeSlug('Hello World')); + } + + public function testMakeSlugHelperHandlesSpecialCharacters() + { + $this->assertSame('hello-world', Hyde::makeSlug('Hello & World!')); + } + + public function testMakeSlugHelperConvertsUppercaseToLowercase() + { + $this->assertSame('hello-world', Hyde::makeSlug('HELLO WORLD')); + $this->assertSame('hello-world', Hyde::makeSlug('HELLO_WORLD')); + } + + public function testMakeSlugHelperHandlesNumbers() + { + $this->assertSame('hello-world-123', Hyde::makeSlug('Hello World 123')); + } + + public function testMakeSlugHelperTransliteratesChineseCharacters() + { + $this->assertSame('ni-hao-shi-jie', Hyde::makeSlug('你好世界')); + } + + public function testMakeSlugHelperTransliteratesJapaneseCharacters() + { + $this->assertSame('konnichihashi-jie', Hyde::makeSlug('こんにちは世界')); + } + + public function testMakeSlugHelperTransliteratesKoreanCharacters() + { + $this->assertSame('annyeongsegye', Hyde::makeSlug('안녕세계')); + } + + public function testMakeSlugHelperTransliteratesArabicCharacters() + { + $this->assertSame('mrhb-bllm', Hyde::makeSlug('مرحبا بالعالم')); + } + + public function testMakeSlugHelperTransliteratesRussianCharacters() + { + $this->assertSame('privet-mir', Hyde::makeSlug('Привет мир')); + } + + public function testMakeSlugHelperTransliteratesAccentedLatinCharacters() + { + $this->assertSame('hello-world', Hyde::makeSlug('hèllô wórld')); + $this->assertSame('uber-strasse', Hyde::makeSlug('über straße')); + } + + public function testMakeSlugHelperHandlesMixedScripts() + { + $this->assertSame('hello-ni-hao-world', Hyde::makeSlug('Hello 你好 World')); + $this->assertSame('privet-world', Hyde::makeSlug('Привет World')); + } + + public function testMakeSlugHelperHandlesEmojis() + { + $this->assertSame('hello-world', Hyde::makeSlug('Hello 👋 World')); + $this->assertSame('world', Hyde::makeSlug('😊 World')); + } + + public function testMakeSlugHelperHandlesComplexMixedInput() + { + $this->assertSame( + 'hello-ni-hao-privet-bonjour-world-123', + Hyde::makeSlug('Hello 你好 Привет Bonjóur World 123!') + ); + } + + public function testMakeSlugHelperHandlesEdgeCases() + { + $this->assertSame('', Hyde::makeSlug('')); + $this->assertSame('at', Hyde::makeSlug('!@#$%^&*()')); + $this->assertSame('', Hyde::makeSlug('... ...')); + $this->assertSame('multiple-dashes', Hyde::makeSlug('multiple---dashes')); + } + + public function testMakeSlugHelperPreservesValidCharacters() + { + $this->assertSame('abc-123', Hyde::makeSlug('abc-123')); + $this->assertSame('test-slug', Hyde::makeSlug('test-slug')); + } + + public function testMakeSlugHelperHandlesWhitespace() + { + $this->assertSame('trim-spaces', Hyde::makeSlug(' trim spaces ')); + $this->assertSame('newline-test', Hyde::makeSlug("newline\ntest")); + $this->assertSame('tab-test', Hyde::makeSlug("tab\ttest")); + } +} diff --git a/packages/framework/tests/Unit/Pages/PageModelGetAllFilesHelperTest.php b/packages/framework/tests/Unit/Pages/PageModelGetAllFilesHelperTest.php deleted file mode 100644 index 6ca5e43d91f..00000000000 --- a/packages/framework/tests/Unit/Pages/PageModelGetAllFilesHelperTest.php +++ /dev/null @@ -1,88 +0,0 @@ -filesystem = $this->mockFilesystemStrict() - ->shouldReceive('missing')->withAnyArgs()->andReturn(false)->byDefault() - ->shouldReceive('get')->withAnyArgs()->andReturn('foo')->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_pages/{*,**/*}.html'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_pages/{*,**/*}.blade.php'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_pages/{*,**/*}.md'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_posts/{*,**/*}.md'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_docs/{*,**/*}.md'), GLOB_BRACE)->andReturn([])->byDefault(); - } - - protected function tearDown(): void - { - $this->verifyMockeryExpectations(); - } - - public function testBladePageGetHelperReturnsBladePageArray() - { - $this->shouldReceiveGlob('_pages/{*,**/*}.blade.php')->andReturn(['_pages/test-page.blade.php']); - - $array = BladePage::files(); - $this->assertCount(1, $array); - $this->assertIsArray($array); - $this->assertEquals(['test-page'], $array); - } - - public function testMarkdownPageGetHelperReturnsMarkdownPageArray() - { - $this->shouldReceiveGlob('_pages/{*,**/*}.md')->andReturn(['_pages/test-page.md']); - - $array = MarkdownPage::files(); - $this->assertCount(1, $array); - $this->assertIsArray($array); - $this->assertEquals(['test-page'], $array); - } - - public function testMarkdownPostGetHelperReturnsMarkdownPostArray() - { - $this->shouldReceiveGlob('_posts/{*,**/*}.md')->andReturn(['_posts/test-post.md']); - - $array = MarkdownPost::files(); - $this->assertCount(1, $array); - $this->assertIsArray($array); - $this->assertEquals(['test-post'], $array); - } - - public function testDocumentationPageGetHelperReturnsDocumentationPageArray() - { - $this->shouldReceiveGlob('_docs/{*,**/*}.md')->andReturn(['_docs/test-page.md']); - - $array = DocumentationPage::files(); - $this->assertCount(1, $array); - $this->assertIsArray($array); - $this->assertEquals(['test-page'], $array); - } - - protected function shouldReceiveGlob(string $withPath): ExpectationInterface - { - return $this->filesystem->shouldReceive('glob')->once()->with(Hyde::path($withPath), GLOB_BRACE); - } -} diff --git a/packages/framework/tests/Unit/Pages/PageModelGetFileHelpersTest.php b/packages/framework/tests/Unit/Pages/PageModelGetFileHelpersTest.php new file mode 100644 index 00000000000..0586b3110d3 --- /dev/null +++ b/packages/framework/tests/Unit/Pages/PageModelGetFileHelpersTest.php @@ -0,0 +1,115 @@ +withFile('_pages/test-page.blade.php'); + + $array = BladePage::files(); + $this->assertCount(3, $array); + $this->assertIsArray($array); + $this->assertEquals(['404', 'index', 'test-page'], $array); + } + + public function testMarkdownPageFilesHelperReturnsMarkdownPageArray() + { + $this->withFile('_pages/test-page.md'); + + $array = MarkdownPage::files(); + $this->assertCount(1, $array); + $this->assertIsArray($array); + $this->assertEquals(['test-page'], $array); + } + + public function testMarkdownPostFilesHelperReturnsMarkdownPostArray() + { + $this->withFile('_posts/test-post.md'); + + $array = MarkdownPost::files(); + $this->assertCount(1, $array); + $this->assertIsArray($array); + $this->assertEquals(['test-post'], $array); + } + + public function testDocumentationPageFilesHelperReturnsDocumentationPageArray() + { + $this->withFile('_docs/test-page.md'); + + $array = DocumentationPage::files(); + $this->assertCount(1, $array); + $this->assertIsArray($array); + $this->assertEquals(['test-page'], $array); + } + + public function testBladePageAllHelperReturnsBladePageCollection() + { + $this->withFile('_pages/test-page.blade.php'); + + $collection = BladePage::all(); + + $this->assertCount(3, $collection); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertContainsOnlyInstancesOf(BladePage::class, $collection); + } + + public function testMarkdownPageAllHelperReturnsMarkdownPageCollection() + { + $this->withFile('_pages/test-page.md'); + + $collection = MarkdownPage::all(); + $this->assertCount(1, $collection); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertContainsOnlyInstancesOf(MarkdownPage::class, $collection); + } + + public function testMarkdownPostAllHelperReturnsMarkdownPostCollection() + { + $this->withFile('_posts/test-post.md'); + + $collection = MarkdownPost::all(); + $this->assertCount(1, $collection); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertContainsOnlyInstancesOf(MarkdownPost::class, $collection); + } + + public function testDocumentationPageAllHelperReturnsDocumentationPageCollection() + { + $this->withFile('_docs/test-page.md'); + + $collection = DocumentationPage::all(); + $this->assertCount(1, $collection); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertContainsOnlyInstancesOf(DocumentationPage::class, $collection); + } + + protected function withFile(string $path): void + { + $this->file($path); + + HydeKernel::getInstance()->boot(); + } + + protected function tearDown(): void + { + $this->cleanupFilesystem(); + } +} diff --git a/packages/framework/tests/Unit/Pages/PageModelGetHelperTest.php b/packages/framework/tests/Unit/Pages/PageModelGetHelperTest.php deleted file mode 100644 index de9e3acb239..00000000000 --- a/packages/framework/tests/Unit/Pages/PageModelGetHelperTest.php +++ /dev/null @@ -1,96 +0,0 @@ -filesystem = $this->mockFilesystemStrict() - ->shouldReceive('glob')->once()->with(Hyde::path('_pages/{*,**/*}.html'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_pages/{*,**/*}.blade.php'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_pages/{*,**/*}.md'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_posts/{*,**/*}.md'), GLOB_BRACE)->andReturn([])->byDefault() - ->shouldReceive('glob')->once()->with(Hyde::path('_docs/{*,**/*}.md'), GLOB_BRACE)->andReturn([])->byDefault(); - } - - protected function tearDown(): void - { - $this->verifyMockeryExpectations(); - } - - public function testBladePageGetHelperReturnsBladePageCollection() - { - $this->shouldReceiveGlob('_pages/{*,**/*}.blade.php')->andReturn(['_pages/test-page.blade.php']); - $this->shouldFindFile('_pages/test-page.blade.php'); - - $collection = BladePage::all(); - $this->assertCount(1, $collection); - $this->assertInstanceOf(Collection::class, $collection); - $this->assertContainsOnlyInstancesOf(BladePage::class, $collection); - } - - public function testMarkdownPageGetHelperReturnsMarkdownPageCollection() - { - $this->shouldReceiveGlob('_pages/{*,**/*}.md')->andReturn(['_pages/test-page.md']); - $this->shouldFindFile('_pages/test-page.md'); - - $collection = MarkdownPage::all(); - $this->assertCount(1, $collection); - $this->assertInstanceOf(Collection::class, $collection); - $this->assertContainsOnlyInstancesOf(MarkdownPage::class, $collection); - } - - public function testMarkdownPostGetHelperReturnsMarkdownPostCollection() - { - $this->shouldReceiveGlob('_posts/{*,**/*}.md')->andReturn(['_posts/test-post.md']); - $this->shouldFindFile('_posts/test-post.md'); - - $collection = MarkdownPost::all(); - $this->assertCount(1, $collection); - $this->assertInstanceOf(Collection::class, $collection); - $this->assertContainsOnlyInstancesOf(MarkdownPost::class, $collection); - } - - public function testDocumentationPageGetHelperReturnsDocumentationPageCollection() - { - $this->shouldReceiveGlob('_docs/{*,**/*}.md')->andReturn(['_docs/test-page.md']); - $this->shouldFindFile('_docs/test-page.md'); - $collection = DocumentationPage::all(); - $this->assertCount(1, $collection); - $this->assertInstanceOf(Collection::class, $collection); - $this->assertContainsOnlyInstancesOf(DocumentationPage::class, $collection); - } - - protected function shouldReceiveGlob(string $withPath): ExpectationInterface - { - return $this->filesystem->shouldReceive('glob')->once()->with(Hyde::path($withPath), GLOB_BRACE); - } - - protected function shouldFindFile(string $file): void - { - $this->filesystem->shouldReceive('missing')->once()->with(Hyde::path($file))->andReturnFalse(); - $this->filesystem->shouldReceive('get')->once()->with(Hyde::path($file))->andReturn('content'); - } -} diff --git a/packages/testing/src/CreatesTemporaryFiles.php b/packages/testing/src/CreatesTemporaryFiles.php index b1a656f4e46..1d05f20f969 100644 --- a/packages/testing/src/CreatesTemporaryFiles.php +++ b/packages/testing/src/CreatesTemporaryFiles.php @@ -35,6 +35,23 @@ protected function file(string $path, ?string $contents = null): void $this->cleanUpWhenDone($path); } + /** + * List of filenames, or map of filenames to contents, of temporary files to create in the project directory. + * + * The test case will automatically remove the files when the test is completed. + */ + protected function files(array $files): void + { + foreach ($files as $path => $contents) { + if (is_int($path)) { + $path = $contents; + $contents = null; + } + + $this->file($path, $contents); + } + } + /** * Create a temporary directory in the project directory. *