├── .commitlintrc.json ├── .env.example ├── .github └── workflows │ ├── close-stale.yml │ ├── coding-conventions.yml │ ├── deploy.yml │ └── lint-pr-title.yml ├── .gitignore ├── .phpunit.result.cache ├── CONTRIBUTING.md ├── LICENCE.md ├── README.md ├── bun.lock ├── composer.json ├── composer.lock ├── deploy.sh ├── eslint.config.mjs ├── mago.toml ├── package.json ├── phpunit.xml ├── public ├── favicon │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest ├── filesaver.min.js ├── fonts │ ├── MonaspaceArgon-Bold.woff │ ├── MonaspaceArgon-ExtraBold.woff │ ├── MonaspaceNeon-Light.woff │ ├── MonaspaceNeon-Medium.woff │ ├── MonaspaceNeon-Regular.woff │ ├── MonaspaceRadon-Light.woff │ └── MonaspaceRadon-Regular.woff ├── html2canvas.min.js ├── img │ ├── alpha-4-console-wip.mp4 │ ├── alpha-5-console-task.mp4 │ ├── alpha-5-vite.mp4 │ ├── ask-a.mp4 │ ├── ask-b.mp4 │ ├── ask-c.mp4 │ ├── bg-dark-theme-2@2x.jpg │ ├── bg-dark-theme@2x.jpg │ ├── confirm.mp4 │ ├── github.svg │ ├── highlight-2.png │ ├── highlight-3.png │ ├── highlight-4.png │ ├── highlight.png │ ├── logo-transparent.svg │ ├── logo.svg │ ├── password.mp4 │ ├── progress.mp4 │ ├── search.mp4 │ ├── tempest-logo.svg │ └── terminal.png ├── index.php ├── noise.svg ├── tempest-logo-square.png ├── tempest-logo-transparent.svg └── tempest-logo.png ├── rendered.xml ├── src ├── Browsershot │ └── BrowsershotInitializer.php ├── Console │ └── DeployCommand.php ├── GitHub │ ├── GetLatestRelease.php │ └── GetStargazersCount.php ├── Highlight │ ├── ExtendedJsonLanguage.php │ ├── HighlighterInitializer.php │ ├── Injections │ │ ├── CommentInjection.php │ │ ├── DimInjection.php │ │ ├── EmphasizeInjection.php │ │ ├── ErrorInjection.php │ │ ├── H1Injection.php │ │ ├── H2Injection.php │ │ ├── QuestionInjection.php │ │ ├── StrongInjection.php │ │ ├── SuccessInjection.php │ │ ├── TempestViewEchoInjection.php │ │ ├── TempestViewPhpInjection.php │ │ └── UnderlineInjection.php │ ├── IsTagInjection.php │ ├── JsonNullPattern.php │ ├── Patterns │ │ └── TempestViewDynamicAttributePattern.php │ ├── TempestConsoleWebLanguage.php │ └── TempestViewLanguage.php ├── Markdown │ ├── Alerts │ │ ├── AlertBlock.php │ │ ├── AlertBlockParser.php │ │ ├── AlertBlockRenderer.php │ │ ├── AlertBlockStartParser.php │ │ └── AlertExtension.php │ ├── CodeBlockRenderer.php │ ├── HandleParser.php │ ├── HeadingRenderer.php │ ├── InlineCodeBlockRenderer.php │ ├── LinkRenderer.php │ ├── MarkdownInitializer.php │ ├── Symbols │ │ ├── AttributeParser.php │ │ └── FqcnParser.php │ └── TempestPackageParser.php ├── Migrations │ └── FixDateTimeFieldsMigration.php ├── StoredEvents │ ├── CreateStoredEventTable.php │ ├── EventsReplayCommand.php │ ├── HasCreatedAtDate.php │ ├── ProjectionDiscovery.php │ ├── Projector.php │ ├── ShouldBeStored.php │ ├── StoredEvent.php │ ├── StoredEventConfig.php │ └── StoredEventMiddleware.php ├── Web │ ├── Analytics │ │ ├── AnalyticsConfig.php │ │ ├── Chart.php │ │ ├── PackageDownloadsListed.php │ │ ├── PackageDownloadsPerDay │ │ │ ├── CreatePackageDownloadsPerDayTable.php │ │ │ ├── PackageDownloadsPerDay.php │ │ │ ├── PackageDownloadsPerDayProjector.php │ │ │ └── UpdatePackageDownloadsPerDayTable.php │ │ ├── PackageDownloadsPerHour │ │ │ ├── CreatePackageDownloadsPerHourTable.php │ │ │ ├── PackageDownloadsPerHour.php │ │ │ ├── PackageDownloadsPerHourProjector.php │ │ │ └── UpdatePackageDownloadsPerHourTable.php │ │ ├── PageVisited.php │ │ ├── ParseLogCommand.php │ │ ├── ParsePackagistCommand.php │ │ ├── StatsController.php │ │ ├── VisitsPerDay │ │ │ ├── AlterVisitsPerDayTable.php │ │ │ ├── CreateVisitsPerDayTable.php │ │ │ ├── VisitsPerDay.php │ │ │ └── VisitsPerDayProjector.php │ │ ├── VisitsPerHour │ │ │ ├── CreateVisitsPerHourTable.php │ │ │ ├── VisitsPerHour.php │ │ │ └── VisitsPerHourProjector.php │ │ ├── chart.view.php │ │ └── stats.view.php │ ├── Blog │ │ ├── Author.php │ │ ├── BlogController.php │ │ ├── BlogDataProvider.php │ │ ├── BlogIndexer.php │ │ ├── BlogPost.php │ │ ├── BlogRepository.php │ │ ├── articles │ │ │ ├── 2024-10-02-alpha-2.md │ │ │ ├── 2024-10-31-alpha-3.md │ │ │ ├── 2024-11-08-unfair-advantage.md │ │ │ ├── 2024-11-15-exit-codes-fallacy.md │ │ │ ├── 2024-11-25-alpha-4.md │ │ │ ├── 2025-01-16-start-with-the-customer-experience.md │ │ │ ├── 2025-01-22-alpha-5.md │ │ │ ├── 2025-02-02-chasing-bugs-down-rabbit-holes.md │ │ │ ├── 2025-03-08-static-websites-with-tempest.md │ │ │ ├── 2025-03-13-request-objects-in-tempest.md │ │ │ ├── 2025-03-16-discovery-explained.md │ │ │ ├── 2025-03-24-alpha-6.md │ │ │ ├── 2025-03-30-about-route-attributes.md │ │ │ ├── 2025-05-08-beta-1.md │ │ │ └── 2025-05-26-tempests-vision.md │ │ ├── index.view.php │ │ ├── rss.view.php │ │ └── show.view.php │ ├── Code │ │ ├── CodeController.php │ │ ├── EllisonController.php │ │ ├── code.view.php │ │ ├── code_preview.view.php │ │ ├── ellison.view.php │ │ └── ellison_preview.view.php │ ├── CommandPalette │ │ ├── .gitignore │ │ ├── Command.php │ │ ├── CommandIndexer.php │ │ ├── IndexCommandPaletteCommand.php │ │ ├── Type.php │ │ ├── base-dialog.vue │ │ ├── command-palette.vue │ │ ├── palette.entrypoint.ts │ │ ├── register-palette.ts │ │ └── use-search.ts │ ├── Documentation │ │ ├── Chapter.php │ │ ├── ChapterController.php │ │ ├── ChapterRepository.php │ │ ├── ChapterView.php │ │ ├── DocumentationDataProvider.php │ │ ├── DocumentationIndexer.php │ │ ├── RedirectMiddleware.php │ │ ├── Version.php │ │ ├── content │ │ │ └── main │ │ │ │ ├── .gitkeep │ │ │ │ ├── 0-getting-started │ │ │ │ ├── 01-introduction.md │ │ │ │ └── 02-installation.md │ │ │ │ ├── 1-essentials │ │ │ │ ├── 01-routing.md │ │ │ │ ├── 02-views.md │ │ │ │ ├── 03-database.md │ │ │ │ ├── 04-console-commands.md │ │ │ │ ├── 05-container.md │ │ │ │ ├── 06-configuration.md │ │ │ │ ├── 07-testing.md │ │ │ │ └── 08-primitive-utilities.md │ │ │ │ ├── 2-features │ │ │ │ ├── 01-mapper.md │ │ │ │ ├── 02-asset-bundling.md │ │ │ │ ├── 03-validation.md │ │ │ │ ├── 04-authentication.md │ │ │ │ ├── 05-file-storage.md │ │ │ │ ├── 06-cache.md │ │ │ │ ├── 07-mail.md │ │ │ │ ├── 08-events.md │ │ │ │ ├── 09-logging.md │ │ │ │ ├── 10-command-bus.md │ │ │ │ ├── 11-scheduling.md │ │ │ │ ├── 12-http-client.md │ │ │ │ ├── 13-static-pages.md │ │ │ │ ├── 14-exception-handling.md │ │ │ │ └── 15-datetime.md │ │ │ │ ├── 3-packages │ │ │ │ ├── 01-highlight.md │ │ │ │ └── 02-console.md │ │ │ │ ├── 4-internals │ │ │ │ ├── 01-bootstrap.md │ │ │ │ └── 02-discovery.md │ │ │ │ └── 5-extra-topics │ │ │ │ ├── 00-roadmap.md │ │ │ │ ├── 01-package-development.md │ │ │ │ ├── 02-standalone-components.md │ │ │ │ └── 03-contributing.md │ │ └── show.view.php │ ├── Homepage │ │ ├── HomeController.php │ │ ├── codeblocks │ │ │ ├── config.md │ │ │ ├── console.md │ │ │ ├── controller.md │ │ │ ├── event-handler.md │ │ │ ├── mapper.md │ │ │ ├── markdown-initializer.md │ │ │ ├── model.md │ │ │ ├── orm.md │ │ │ ├── query.md │ │ │ ├── static-pages.md │ │ │ ├── templating-component.md │ │ │ ├── templating-view.md │ │ │ ├── view-component.md │ │ │ └── view-processor.md │ │ ├── home.view.php │ │ ├── leaves.entrypoint.ts │ │ ├── rain.entrypoint.ts │ │ ├── x-aurora.view.php │ │ ├── x-falling-leaves.view.php │ │ ├── x-home-section.view.php │ │ ├── x-moonlight.view.php │ │ └── x-rain.view.php │ ├── LatestReleaseViewProcessor.php │ ├── Meta │ │ ├── MetaImageController.php │ │ ├── MetaType.php │ │ ├── views │ │ │ ├── blog-index.view.php │ │ │ ├── blog.view.php │ │ │ ├── default.view.php │ │ │ └── documentation.view.php │ │ └── x-meta-image.view.php │ ├── RedirectsController.php │ ├── StargazersViewProcessor.php │ ├── assets │ │ ├── copy-code-blocks.ts │ │ ├── highlight-current-prose-title.ts │ │ ├── highlighting.css │ │ ├── main.entrypoint.css │ │ ├── main.entrypoint.ts │ │ ├── register-fonts.ts │ │ ├── save-scroll.ts │ │ └── typography.css │ ├── x-base.view.php │ ├── x-footer.view.php │ ├── x-header.view.php │ └── x-search.view.php ├── analytics.config.php ├── database.config.php ├── functions.php └── stored-events.config.php ├── tempest ├── tests └── .gitkeep └── vite.config.ts /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "subject-case": [ 5 | 2, 6 | "never", 7 | ["sentence-case", "start-case", "pascal-case", "upper-case"] 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Possible values: local, staging, production, ci, testing, other 2 | ENVIRONMENT=local 3 | 4 | # The base URI that's used for all generated URIs 5 | BASE_URI=http://localhost 6 | 7 | # The CACHE key is used as a global override to turn all caches on or off 8 | # Should be true in production, but null or false in local development 9 | CACHE=null 10 | 11 | # Enable or disable discovery cache 12 | DISCOVERY_CACHE=false 13 | 14 | # Enable or disable config cache 15 | CONFIG_CACHE=false 16 | 17 | # Enable or disable view cache 18 | VIEW_CACHE=false 19 | 20 | # Enable or disable project cache (allround cache) 21 | PROJECT_CACHE=false 22 | 23 | # Overwrite default log paths (null = default) 24 | DEBUG_LOG_PATH=null 25 | SERVER_LOG_PATH=null 26 | -------------------------------------------------------------------------------- /.github/workflows/close-stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues/pull requests." 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | workflow_dispatch: 6 | 7 | jobs: 8 | close-issues: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - uses: actions/stale@v9 15 | with: 16 | start-date: "2024-11-06T12:00:00Z" 17 | exempt-all-milestones: true 18 | days-before-stale: 30 19 | days-before-close: 1 20 | stale-issue-label: "Stale" 21 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 22 | close-issue-message: "This issue was closed because it has been inactive for 1 day since being marked as stale." 23 | stale-pr-label: "Stale" 24 | stale-pr-message: "This pull request is stale because it has been open for 30 days with no activity." 25 | close-pr-message: "This pull request was closed because it has been inactive for 1 day since being marked as stale." 26 | repo-token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/coding-conventions.yml: -------------------------------------------------------------------------------- 1 | name: Coding conventions 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | check-style: 9 | name: Run style check 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: 8.4 18 | coverage: none 19 | 20 | - name: Install dependencies 21 | run: | 22 | composer update --prefer-dist --no-interaction 23 | composer mago:install-binary 24 | 25 | - name: Run Mago 26 | run: | 27 | ./vendor/bin/mago fmt --dry-run 28 | ./vendor/bin/mago lint --reporting-format=github 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Install SSH Key 12 | uses: shimataro/ssh-key-action@v2 13 | with: 14 | key: ${{ secrets.SSH_KEY }} 15 | known_hosts: 'just-a-placeholder-so-we-dont-get-errors' 16 | 17 | - name: Adding Known Hosts 18 | run: ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 19 | 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: '8.4' 27 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 28 | 29 | - name: Install dependencies 30 | run: | 31 | composer update 32 | npm i 33 | 34 | - name: List Installed Dependencies 35 | run: composer show -D 36 | 37 | - name: Get latest GitHub statistics 38 | id: stats 39 | env: 40 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | run: | 42 | set -e 43 | STARS=$(curl -s -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/tempestphp/tempest-framework | jq '.stargazers_count // empty') 44 | TAG=$(curl -s -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/tempestphp/tempest-framework/releases/latest | jq -r '.tag_name // empty') 45 | echo "Stars: $STARS" 46 | echo "Latest version: $TAG" 47 | echo "stars=$STARS" >> "$GITHUB_OUTPUT" 48 | echo "latest_tag=$TAG" >> "$GITHUB_OUTPUT" 49 | 50 | - name: Deploy 51 | run: php ./tempest deploy 52 | env: 53 | TEMPEST_BUILD_STARGAZERS: ${{ steps.stats.outputs.stars }} 54 | TEMPEST_BUILD_LATEST_RELEASE: ${{ steps.stats.outputs.latest_tag }} 55 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Lint pull request title 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, synchronize, reopened] 6 | 7 | jobs: 8 | lint-pr-title: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Setup Bun 17 | uses: oven-sh/setup-bun@v2 18 | 19 | - name: Install dependencies 20 | run: | 21 | bun install @commitlint/config-conventional @commitlint/cli 22 | 23 | - name: Lint title 24 | env: 25 | PR_TITLE: ${{ github.event.pull_request.title }} 26 | run: | 27 | echo "$PR_TITLE" | bunx commitlint 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | vendor/ 3 | /public/main.css 4 | /package-lock.json 5 | .env 6 | .idea 7 | log 8 | public/**/**.html 9 | public/meta 10 | app/database.sqlite 11 | access.log 12 | access_big.log 13 | access_short.log 14 | sync-db.sh 15 | sync-log.sh 16 | tempest-access.log 17 | vite-tempest 18 | public/build/ 19 | database.sqlite 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Tempest's documentation 2 | 3 | ## Installing the project 4 | 5 | First, clone and install the project. 6 | 7 | ```sh 8 | # Clone the repository 9 | git clone git@github.com:tempestphp/tempest-docs.git 10 | cd tempest-docs 11 | 12 | # Install dependencies 13 | composer install 14 | bun install 15 | 16 | # Start the servers in two terminals 17 | bun run dev 18 | php tempest serve # not useful if you have Herd or Valet 19 | ``` 20 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Brent Roose brendt@stitcher.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 |

Tempest documentation

8 |
9 | This repository hosts the source code for the Tempest website. 10 |
11 | The source code for the framework itself can be found at tempestphp/tempest-framework. 12 |
13 |
14 | 15 | Check out the documentation 16 |   17 | · 18 |   19 | Join the Discord server 20 | 21 |

22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tempest/docs", 3 | "type": "project", 4 | "description": "Documentation website for the Tempest framework", 5 | "require": { 6 | "tempest/framework": "dev-main", 7 | "league/commonmark": "^2.7.0", 8 | "symfony/yaml": "^6.4.21", 9 | "spatie/yaml-front-matter": "^2.1", 10 | "spatie/browsershot": "^4.4", 11 | "assertchris/ellison": "^1.0.2" 12 | }, 13 | "require-dev": { 14 | "phpunit/phpunit": "^10.5.46", 15 | "symfony/var-dumper": "^7.2.6", 16 | "carthage-software/mago": "^0.19.5" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "App\\": "src/" 21 | }, 22 | "files": [ 23 | "src/functions.php" 24 | ] 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Tests\\": "tests/", 29 | "Tests\\Tempest\\": "vendor/brendt/tempest/tests/" 30 | } 31 | }, 32 | "authors": [ 33 | { 34 | "name": "Brent Roose", 35 | "email": "brendt@stitcher.io" 36 | } 37 | ], 38 | "scripts": { 39 | "post-autoload-dump": [ 40 | "@php ./tempest discovery:generate" 41 | ], 42 | "phpunit": "vendor/bin/phpunit --display-warnings --display-skipped --display-deprecations --display-errors --display-notices", 43 | "mago:fmt": "vendor/bin/mago fmt", 44 | "mago:lint": "vendor/bin/mago lint --fix && vendor/bin/mago lint", 45 | "qa": [ 46 | "composer mago:fmt", 47 | "composer phpunit", 48 | "composer mago:lint", 49 | "@php ./tempest static:generate", 50 | "@php ./tempest static:clean" 51 | ] 52 | }, 53 | "minimum-stability": "dev", 54 | "prefer-stable": true, 55 | "license": "MIT", 56 | "config": { 57 | "allow-plugins": { 58 | "php-http/discovery": true, 59 | "carthage-software/mago": true 60 | 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . /home/forge/.bashrc 4 | . ~/.nvm/nvm.sh 5 | 6 | # Dependencies 7 | php8.4 /usr/local/bin/composer install --no-dev 8 | /home/forge/.bun/bin/bun --version 9 | /home/forge/.bun/bin/bun install 10 | 11 | # Tempest 12 | php8.4 tempest cache:clear --force --internal --all 13 | php8.4 tempest discovery:generate 14 | php8.4 tempest migrate:up --force 15 | php8.4 tempest static:clean --force 16 | 17 | # Build front-end 18 | php8.4 tempest command-palette:index 19 | /home/forge/.bun/bin/bun run build 20 | php8.4 tempest static:generate --allow-dead-links --verbose=true 21 | 22 | # Supervisor 23 | sudo supervisorctl restart all 24 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import defineEslintConfig from '@innocenzi/eslint-config' 2 | 3 | export default defineEslintConfig({ 4 | tailwindcss: false, // https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/384 5 | ignores: ['.github', 'public', '*.json', '**/composer.json'], 6 | }) 7 | -------------------------------------------------------------------------------- /mago.toml: -------------------------------------------------------------------------------- 1 | php_version = "8.4.0" 2 | 3 | [source] 4 | paths = ["src"] 5 | includes = ["vendor"] 6 | excludes = [ 7 | "./vendor/symfony/cache/Traits/ValueWrapper.php", 8 | "./vendor/composer", 9 | ] 10 | 11 | [format] 12 | print_width = 180 13 | tab_width = 4 14 | use_tabs = false 15 | space_after_not_operator = true 16 | null_type_hint = "question" 17 | space_before_arrow_function_params = true 18 | always_break_named_arguments_list = false 19 | preserve_breaking_member_access_chain = true 20 | preserve_breaking_argument_list = true 21 | preserve_breaking_array_like = true 22 | preserve_breaking_parameter_list = true 23 | preserve_breaking_attribute_list = true 24 | preserve_breaking_conditional_expression = true 25 | 26 | [linter] 27 | default_plugins = true 28 | plugins = ["symfony", "php-unit"] 29 | 30 | # MAINTENABILITY 31 | [[linter.rules]] 32 | name = "maintainability/too-many-enum-cases" 33 | level = "off" 34 | 35 | [[linter.rules]] 36 | name = "maintainability/excessive-parameter-list" 37 | level = "off" 38 | 39 | [[linter.rules]] 40 | name = "maintainability/halstead" 41 | level = "off" 42 | 43 | [[linter.rules]] 44 | name = "maintainability/too-many-methods" 45 | level = "off" 46 | 47 | [[linter.rules]] 48 | name = "maintainability/kan-defect" 49 | level = "off" 50 | 51 | [[linter.rules]] 52 | name = "maintainability/cyclomatic-complexity" 53 | level = "off" 54 | 55 | # STRICTNESS 56 | [[linter.rules]] 57 | name = "strictness/require-return-type" 58 | ignore_arrow_function = true 59 | ignore_closure = true 60 | 61 | [[linter.rules]] 62 | name = "strictness/require-strict-types" 63 | level = "off" 64 | 65 | [[linter.rules]] 66 | name = "strictness/require-parameter-type" 67 | ignore_arrow_function = true 68 | ignore_closure = true 69 | 70 | [[linter.rules]] 71 | name = "strictness/no-shorthand-ternary" 72 | level = "off" 73 | 74 | [[linter.rules]] 75 | name = "strictness/no-assignment-in-condition" 76 | level = "off" 77 | 78 | # BEST PRACTICES 79 | [[linter.rules]] 80 | name = "best-practices/no-else-clause" 81 | level = "off" 82 | 83 | [[linter.rules]] 84 | name = "best-practices/no-boolean-literal-comparison" 85 | level = "off" 86 | 87 | [[linter.rules]] 88 | name = "best-practices/no-boolean-flag-parameter" 89 | level = "off" 90 | 91 | # SAFETY 92 | [[linter.rules]] 93 | name = "safety/no-error-control-operator" 94 | level = "off" 95 | 96 | # PHPUNIT 97 | [[linter.rules]] 98 | name = "php-unit/assertions-style" 99 | style = "this" 100 | 101 | [[linter.rules]] 102 | name = "php-unit/strict-assertions" 103 | level = "off" 104 | 105 | # NAMING 106 | [[linter.rules]] 107 | name = "naming/interface" 108 | psr = false 109 | 110 | [[linter.rules]] 111 | name = "naming/trait" 112 | psr = false 113 | 114 | [[linter.rules]] 115 | name = "naming/class" 116 | psr = false 117 | 118 | # HELP 119 | [[linter.rules]] 120 | name = "redundancy/redundant-file" 121 | level = "off" 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build" 6 | }, 7 | "dependencies": { 8 | "highlight.js": "^11.11.1", 9 | "puppeteer": "^24.8.2" 10 | }, 11 | "devDependencies": { 12 | "@fontsource-variable/kantumruy-pro": "^5.2.5", 13 | "@fontsource-variable/public-sans": "^5.2.5", 14 | "@innocenzi/eslint-config": "^0.22.8", 15 | "@tailwindcss/typography": "^0.5.16", 16 | "@tailwindcss/vite": "^4.1.7", 17 | "@vitejs/plugin-vue": "^5.2.4", 18 | "@vueuse/core": "^13.2.0", 19 | "eslint": "^9.27.0", 20 | "fuse.js": "^7.1.0", 21 | "reka-ui": "^2.2.1", 22 | "tailwindcss": "^4.1.7", 23 | "typescript": "^5.8.3", 24 | "vite": "^6.3.5", 25 | "vite-plugin-tempest": "^0.0.2", 26 | "vue": "^3.5.14" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests 15 | 16 | 17 | 18 | 19 | 20 | ./app 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/favicon/favicon.ico -------------------------------------------------------------------------------- /public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /public/filesaver.min.js: -------------------------------------------------------------------------------- 1 | (function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)}); 2 | 3 | //# sourceMappingURL=FileSaver.min.js.map -------------------------------------------------------------------------------- /public/fonts/MonaspaceArgon-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/fonts/MonaspaceArgon-Bold.woff -------------------------------------------------------------------------------- /public/fonts/MonaspaceArgon-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/fonts/MonaspaceArgon-ExtraBold.woff -------------------------------------------------------------------------------- /public/fonts/MonaspaceNeon-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/fonts/MonaspaceNeon-Light.woff -------------------------------------------------------------------------------- /public/fonts/MonaspaceNeon-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/fonts/MonaspaceNeon-Medium.woff -------------------------------------------------------------------------------- /public/fonts/MonaspaceNeon-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/fonts/MonaspaceNeon-Regular.woff -------------------------------------------------------------------------------- /public/fonts/MonaspaceRadon-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/fonts/MonaspaceRadon-Light.woff -------------------------------------------------------------------------------- /public/fonts/MonaspaceRadon-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/fonts/MonaspaceRadon-Regular.woff -------------------------------------------------------------------------------- /public/img/alpha-4-console-wip.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/alpha-4-console-wip.mp4 -------------------------------------------------------------------------------- /public/img/alpha-5-console-task.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/alpha-5-console-task.mp4 -------------------------------------------------------------------------------- /public/img/alpha-5-vite.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/alpha-5-vite.mp4 -------------------------------------------------------------------------------- /public/img/ask-a.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/ask-a.mp4 -------------------------------------------------------------------------------- /public/img/ask-b.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/ask-b.mp4 -------------------------------------------------------------------------------- /public/img/ask-c.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/ask-c.mp4 -------------------------------------------------------------------------------- /public/img/bg-dark-theme-2@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/bg-dark-theme-2@2x.jpg -------------------------------------------------------------------------------- /public/img/bg-dark-theme@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/bg-dark-theme@2x.jpg -------------------------------------------------------------------------------- /public/img/confirm.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/confirm.mp4 -------------------------------------------------------------------------------- /public/img/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/img/highlight-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/highlight-2.png -------------------------------------------------------------------------------- /public/img/highlight-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/highlight-3.png -------------------------------------------------------------------------------- /public/img/highlight-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/highlight-4.png -------------------------------------------------------------------------------- /public/img/highlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/highlight.png -------------------------------------------------------------------------------- /public/img/logo-transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/img/password.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/password.mp4 -------------------------------------------------------------------------------- /public/img/progress.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/progress.mp4 -------------------------------------------------------------------------------- /public/img/search.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/search.mp4 -------------------------------------------------------------------------------- /public/img/tempest-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/img/terminal.png -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | run(); 8 | 9 | exit; -------------------------------------------------------------------------------- /public/noise.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 8 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/tempest-logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/tempest-logo-square.png -------------------------------------------------------------------------------- /public/tempest-logo-transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/tempest-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/public/tempest-logo.png -------------------------------------------------------------------------------- /rendered.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tempest/console 5 | is a standalone package used to build console applications. 6 | 7 | 8 | Give it a ⭐️ on GitHub 9 | 10 | 11 | ! 12 | 13 | 14 | You can install 15 | tempest/console 16 | like so: 17 | 18 | composer require tempest/console:1.0-alpha.5 19 | 20 | 21 | And run it like so: 22 | 23 | {:hl-comment:#!/usr/bin/env php:} 24 | <?php 25 | 26 | use Tempest\Console\ConsoleApplication; 27 | 28 | require_once __DIR__ . '/vendor/autoload.php'; 29 | 30 | ConsoleApplication::boot()->run(); 31 | 32 | 33 | Configuration 34 | 35 | 36 | 37 | tempest/console 38 | uses on Tempest's discovery to find and register console commands. That means you don't have to register any commands manually, and any method within your codebase using the 39 | {php}#[ConsoleCommand] 40 | attribute will automatically be discovered by your console application. 41 | 42 | // app/InteractiveCommand.php 43 | 44 | use Tempest\Console\Console; 45 | use Tempest\Console\ConsoleCommand; 46 | 47 | final readonly class InteractiveCommand 48 | { 49 | public function __construct( 50 | private Console $console, 51 | ) {} 52 | 53 | #[ConsoleCommand('hello:world')] 54 | public function __invoke(): void 55 | { 56 | $this->console->writeln('Hello World!'); 57 | } 58 | } 59 | 60 | 61 | Tempest will discover all console commands within namespaces configured as composer PSR-4 autoload namespaces, as well as all third-party packages that require Tempest. 62 | 63 | "autoload": { 64 | "psr-4": { 65 | "App\\": "app/" 66 | } 67 | }, 68 | 69 | 70 | In case you need more fine-grained control over which directories to discover, you can provide a custom 71 | {php}AppConfig 72 | instance to the 73 | {php}ConsoleApplication::boot() 74 | method: 75 | 76 | use Tempest\AppConfig; 77 | use Tempest\Core\DiscoveryLocation; 78 | use Tempest\Console\ConsoleApplication; 79 | 80 | $appConfig = new AppConfig( 81 | discoveryLocations: [ 82 | new DiscoveryLocation( 83 | namespace: 'App\\', 84 | path: __DIR__ . '/app/', 85 | ), 86 | ], 87 | ); 88 | 89 | ConsoleApplication::boot(appConfig: $appConfig)->run(); 90 | 91 | 92 | -------------------------------------------------------------------------------- /src/Browsershot/BrowsershotInitializer.php: -------------------------------------------------------------------------------- 1 | setOption('args', ['--disable-web-security']) 20 | ->windowSize(1200, 628) 21 | ->deviceScaleFactor(2); 22 | 23 | if ($includePath = env('BROWSERSHOT_PATH')) { 24 | $browsershot->setIncludePath("{$includePath}:\$PATH"); 25 | } 26 | 27 | if ($nodePath = env('BROWSERSHOT_NODE_PATH')) { 28 | $browsershot->setNodeBinary($nodePath); 29 | } 30 | 31 | if ($npmPath = env('BROWSERSHOT_NPM_PATH')) { 32 | $browsershot->setNpmBinary($npmPath); 33 | } 34 | 35 | return $browsershot; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Console/DeployCommand.php: -------------------------------------------------------------------------------- 1 | info('Starting deploy'); 18 | 19 | $this->info('Pulling changes'); 20 | passthru("ssh forge@stitcher.io 'cd tempest.stitcher.io && git fetch origin && git reset --hard origin/main'"); 21 | $this->success('Done'); 22 | 23 | $this->info('Running deploy script'); 24 | passthru("ssh forge@stitcher.io 'cd tempest.stitcher.io && bash deploy.sh'"); 25 | 26 | $this->success('Deploy success'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/GitHub/GetLatestRelease.php: -------------------------------------------------------------------------------- 1 | httpClient 29 | ->get('https://api.github.com/repos/tempestphp/tempest-framework/releases/latest') 30 | ->body; 31 | 32 | return json_decode($body)->tag_name ?? $defaultRelease; 33 | } catch (Throwable) { 34 | return $defaultRelease; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/GitHub/GetStargazersCount.php: -------------------------------------------------------------------------------- 1 | getStargazersCount()) { 21 | return Number\to_human_readable($stargazers, maxPrecision: 1); 22 | } 23 | 24 | return null; 25 | } 26 | 27 | private function getStargazersCount(): ?int 28 | { 29 | if ($stargazers = env('TEMPEST_BUILD_STARGAZERS')) { 30 | return $stargazers; 31 | } 32 | 33 | try { 34 | $body = $this->httpClient 35 | ->get(uri: 'https://api.github.com/repos/tempestphp/tempest-framework') 36 | ->body; 37 | 38 | return $stargazers = json_decode($body)->stargazers_count ?? null; 39 | } catch (\Throwable) { 40 | return null; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Highlight/ExtendedJsonLanguage.php: -------------------------------------------------------------------------------- 1 | addLanguage(new TempestViewLanguage()) 21 | ->addLanguage(new TempestConsoleWebLanguage()) 22 | ->addLanguage(new ExtendedJsonLanguage()); 23 | 24 | return $highlighter; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Highlight/Injections/CommentInjection.php: -------------------------------------------------------------------------------- 1 | 1) { 25 | $comment = implode( 26 | PHP_EOL, 27 | [ 28 | '/*', 29 | ...array_map( 30 | fn (string $line) => " * {$line}", 31 | $lines, 32 | ), 33 | ' */', 34 | ], 35 | ); 36 | } else { 37 | $comment = '// ' . $lines[0]; 38 | } 39 | 40 | return sprintf( 41 | '%s%s%s', 42 | Escape::tokens(''), 43 | $comment, 44 | Escape::tokens(''), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Highlight/Injections/DimInjection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/EmphasizeInjection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/ErrorInjection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/H1Injection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/H2Injection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/QuestionInjection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/StrongInjection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/SuccessInjection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/Injections/TempestViewEchoInjection.php: -------------------------------------------------------------------------------- 1 | .*)(!!}|}})'; 17 | } 18 | 19 | public function parseContent(string $content, Highlighter $highlighter): string 20 | { 21 | return $highlighter->parse($content, new PhpLanguage()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Highlight/Injections/TempestViewPhpInjection.php: -------------------------------------------------------------------------------- 1 | .*?)"/'; 17 | } 18 | 19 | public function parseContent(string $content, Highlighter $highlighter): string 20 | { 21 | return $highlighter->parse($content, new PhpLanguage()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Highlight/Injections/UnderlineInjection.php: -------------------------------------------------------------------------------- 1 | '), 25 | $content, 26 | Escape::tokens(''), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Highlight/IsTagInjection.php: -------------------------------------------------------------------------------- 1 | getTag(); 21 | 22 | return '(?\<' . $tag . '\>(.|\n)*?\<\/' . $tag . '\>)'; 23 | } 24 | 25 | public function parseContent(string $content, Highlighter $highlighter): string 26 | { 27 | $tag = $this->getTag(); 28 | 29 | $content = str_replace(["<{$tag}>", ""], '', $content); 30 | 31 | return $this->style($content); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Highlight/JsonNullPattern.php: -------------------------------------------------------------------------------- 1 | null)'; 19 | } 20 | 21 | #[\Override] 22 | public function getTokenType(): TokenType 23 | { 24 | return new DynamicTokenType('hl-null'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Highlight/Patterns/TempestViewDynamicAttributePattern.php: -------------------------------------------------------------------------------- 1 | \w+)'; 17 | } 18 | 19 | #[\Override] 20 | public function getTokenType(): TokenType 21 | { 22 | return TokenTypeEnum::PROPERTY; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Highlight/TempestConsoleWebLanguage.php: -------------------------------------------------------------------------------- 1 | block = new AlertBlock($alertType, $icon, $title); 22 | } 23 | 24 | #[\Override] 25 | public function addLine(string $line): void 26 | { 27 | } 28 | 29 | #[\Override] 30 | public function getBlock(): AbstractBlock 31 | { 32 | return $this->block; 33 | } 34 | 35 | #[\Override] 36 | public function isContainer(): bool 37 | { 38 | return true; 39 | } 40 | 41 | #[\Override] 42 | public function canContain(AbstractBlock $childBlock): bool 43 | { 44 | return true; 45 | } 46 | 47 | #[\Override] 48 | public function canHaveLazyContinuationLines(): bool 49 | { 50 | return false; 51 | } 52 | 53 | public function parseInlines(): bool 54 | { 55 | return true; 56 | } 57 | 58 | #[\Override] 59 | public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue 60 | { 61 | if ($cursor->isIndented()) { 62 | return BlockContinue::at($cursor); 63 | } 64 | 65 | $match = RegexHelper::matchFirst('/^:::$/', $cursor->getLine()); 66 | if ($match !== null) { 67 | $this->finished = true; 68 | return BlockContinue::finished(); 69 | } 70 | 71 | return BlockContinue::at($cursor); 72 | } 73 | 74 | #[\Override] 75 | public function closeBlock(): void 76 | { 77 | // Nothing to do here 78 | } 79 | 80 | public function isFinished(): bool 81 | { 82 | return $this->finished; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Markdown/Alerts/AlertBlockRenderer.php: -------------------------------------------------------------------------------- 1 | icon) { 28 | 'false' => null, 29 | null => match ($node->alertType) { 30 | 'warning' => 'tabler:exclamation-circle', 31 | 'info' => 'tabler:info-circle', 32 | 'success' => 'tabler:check-circle', 33 | 'error' => 'tabler:exclamation-circle', 34 | default => null, 35 | }, 36 | default => $node->icon, 37 | }; 38 | 39 | $icon = $iconName ? get(Icon::class)->render($iconName, class: 'alert-icon') : null; 40 | 41 | $content = new HtmlElement( 42 | tagName: 'div', 43 | attributes: ['class' => 'alert-wrapper'], 44 | contents: [ 45 | $icon ? new HtmlElement('div', attributes: ['class' => 'alert-icon-wrapper'], contents: $icon) : null, 46 | new HtmlElement( 47 | tagName: 'div', 48 | attributes: ['class' => 'alert-content'], 49 | contents: [ 50 | $node->title ? new HtmlElement('span', attributes: ['class' => 'alert-title'], contents: $node->title) : null, 51 | $childRenderer->renderNodes($node->children()), 52 | ], 53 | ), 54 | ], 55 | ); 56 | 57 | return new HtmlElement( 58 | 'div', 59 | ['class' => "alert alert-{$node->alertType}"], 60 | $content, 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Markdown/Alerts/AlertBlockStartParser.php: -------------------------------------------------------------------------------- 1 | isIndented()) { 17 | return BlockStart::none(); 18 | } 19 | 20 | $match = RegexHelper::matchFirst('/^:::(?!group)(?[a-z]+)({(?.*?)})? ?(?.*?)$/i', $cursor->getLine()); 21 | 22 | if ($match === null) { 23 | return BlockStart::none(); 24 | } 25 | 26 | $cursor->advanceToEnd(); 27 | 28 | $alertType = $match['type']; 29 | $icon = $match['icon'] ?: null; 30 | $title = $match['title'] ?: null; 31 | 32 | return BlockStart::of(new AlertBlockParser($alertType, $icon, $title))->at($cursor); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Markdown/Alerts/AlertExtension.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown\Alerts; 4 | 5 | use League\CommonMark\Environment\EnvironmentBuilderInterface; 6 | use League\CommonMark\Extension\ExtensionInterface; 7 | 8 | final class AlertExtension implements ExtensionInterface 9 | { 10 | #[\Override] 11 | public function register(EnvironmentBuilderInterface $environment): void 12 | { 13 | $environment->addBlockStartParser(new AlertBlockStartParser()); 14 | $environment->addRenderer(AlertBlock::class, new AlertBlockRenderer()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Markdown/CodeBlockRenderer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace App\Markdown; 6 | 7 | use InvalidArgumentException; 8 | use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; 9 | use League\CommonMark\Node\Node; 10 | use League\CommonMark\Renderer\ChildNodeRendererInterface; 11 | use League\CommonMark\Renderer\NodeRendererInterface; 12 | use Tempest\Highlight\Highlighter; 13 | use Tempest\Highlight\WebTheme; 14 | 15 | final class CodeBlockRenderer implements NodeRendererInterface 16 | { 17 | public function __construct( 18 | private Highlighter $highlighter = new Highlighter(), 19 | ) { 20 | } 21 | 22 | #[\Override] 23 | public function render(Node $node, ChildNodeRendererInterface $childRenderer): string 24 | { 25 | if (! ($node instanceof FencedCode)) { 26 | throw new InvalidArgumentException('Block must be instance of ' . FencedCode::class); 27 | } 28 | 29 | preg_match('/^(?<language>[\w]+)(\{(?<startAt>[\d]+)\})?/', $node->getInfoWords()[0] ?? 'txt', $matches); 30 | 31 | $highlighter = $this->highlighter; 32 | 33 | if ($startAt = $matches['startAt'] ?? null) { 34 | $highlighter = $highlighter->withGutter((int) $startAt); 35 | } 36 | 37 | $language = $matches['language'] ?? 'txt'; 38 | $parsed = $highlighter->parse($node->getLiteral(), $language); 39 | $theme = $highlighter->getTheme(); 40 | 41 | if ($theme instanceof WebTheme) { 42 | $pre = $theme->preBefore($highlighter) . $parsed . $theme->preAfter($highlighter); 43 | 44 | if ($node->getInfoWords()[1] ?? false) { 45 | return <<<HTML 46 | <div class="code-block named-code-block"> 47 | <div class="code-block-name">{$node->getInfoWords()[1]}</div> 48 | {$pre} 49 | </div> 50 | HTML; 51 | } 52 | 53 | return $pre; 54 | } 55 | 56 | return '<pre data-lang="' . $language . '" class="notranslate">' . $parsed . '</pre>'; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Markdown/HandleParser.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown; 4 | 5 | use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 6 | use League\CommonMark\Parser\Inline\InlineParserInterface; 7 | use League\CommonMark\Parser\Inline\InlineParserMatch; 8 | use League\CommonMark\Parser\InlineParserContext; 9 | 10 | use function Tempest\Support\str; 11 | 12 | final readonly class HandleParser implements InlineParserInterface 13 | { 14 | #[\Override] 15 | public function getMatchDefinition(): InlineParserMatch 16 | { 17 | return InlineParserMatch::regex('{(twitter|x|bluesky|bsky|gh|github):(.+?)(?:,(.+?))?}'); 18 | } 19 | 20 | #[\Override] 21 | public function parse(InlineParserContext $inlineContext): bool 22 | { 23 | $cursor = $inlineContext->getCursor(); 24 | $previousChar = $cursor->peek(-1); 25 | 26 | if ($previousChar !== null && $previousChar !== ' ') { 27 | return false; 28 | } 29 | 30 | $cursor->advanceBy($inlineContext->getFullMatchLength()); 31 | 32 | [$platform, $handle, $text] = $inlineContext->getSubMatches() + [null, null, null]; 33 | 34 | $url = match ($platform) { 35 | 'bluesky', 'bsky' => "https://bsky.app/profile/$handle", 36 | 'gh', 'github' => "https://github.com/$handle", 37 | 'x', 'twitter' => "https://x.com/$handle", 38 | default => throw new \RuntimeException("Unknown platform: $platform"), 39 | }; 40 | 41 | $inlineContext->getContainer()->appendChild( 42 | new Link($url, label: $text ?? ('@' . $handle)), 43 | ); 44 | 45 | return true; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Markdown/HeadingRenderer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown; 4 | 5 | use InvalidArgumentException; 6 | use League\CommonMark\Extension\CommonMark\Node\Block\Heading; 7 | use League\CommonMark\Node\Node; 8 | use League\CommonMark\Renderer\ChildNodeRendererInterface; 9 | use League\CommonMark\Renderer\NodeRendererInterface; 10 | use League\CommonMark\Util\HtmlElement; 11 | use Tempest\Support\Str\ImmutableString; 12 | 13 | final class HeadingRenderer implements NodeRendererInterface 14 | { 15 | #[\Override] 16 | public function render(Node $node, ChildNodeRendererInterface $childRenderer): ?string 17 | { 18 | if (! ($node instanceof Heading)) { 19 | throw new InvalidArgumentException('Block must be instance of ' . Heading::class); 20 | } 21 | 22 | $tag = 'h' . $node->getLevel(); 23 | $attrs = $node->data->get('attributes'); 24 | $slug = new ImmutableString($childRenderer->renderNodes($node->children())) 25 | ->stripTags() 26 | ->kebab() 27 | ->toString(); 28 | 29 | $svg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 9h14M5 15h14M11 4L7 20M17 4l-4 16"/></svg>'; 30 | 31 | return new HtmlElement( 32 | tagName: $tag, 33 | attributes: [ 34 | ...$attrs, 35 | 'id' => $slug, 36 | ], 37 | contents: new HtmlElement( 38 | tagName: 'a', 39 | attributes: ['href' => '#' . $slug, 'class' => 'heading-permalink'], 40 | contents: [ 41 | new HtmlElement('span', contents: $svg), 42 | $childRenderer->renderNodes($node->children()), 43 | ], 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Markdown/InlineCodeBlockRenderer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown; 4 | 5 | use InvalidArgumentException; 6 | use League\CommonMark\Extension\CommonMark\Node\Inline\Code; 7 | use League\CommonMark\Node\Node; 8 | use League\CommonMark\Renderer\ChildNodeRendererInterface; 9 | use League\CommonMark\Renderer\NodeRendererInterface; 10 | use Tempest\Highlight\Highlighter; 11 | 12 | final class InlineCodeBlockRenderer implements NodeRendererInterface 13 | { 14 | public function __construct( 15 | private Highlighter $highlighter = new Highlighter(), 16 | ) { 17 | } 18 | 19 | #[\Override] 20 | public function render(Node $node, ChildNodeRendererInterface $childRenderer): ?string 21 | { 22 | if (! ($node instanceof Code)) { 23 | throw new InvalidArgumentException('Block must be instance of ' . Code::class); 24 | } 25 | 26 | preg_match('/^\{(?<match>[\w]+)}(?<code>.*)/', $node->getLiteral(), $match); 27 | 28 | $language = $match['match'] ?? 'php'; 29 | $code = $match['code'] ?? $node->getLiteral(); 30 | 31 | return '<code>' . $this->highlighter->parse($code, $language) . '</code>'; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Markdown/LinkRenderer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown; 4 | 5 | use InvalidArgumentException; 6 | use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 7 | use League\CommonMark\Extension\CommonMark\Renderer\Inline\LinkRenderer as InlineLinkRenderer; 8 | use League\CommonMark\Node\Node; 9 | use League\CommonMark\Renderer\ChildNodeRendererInterface; 10 | use League\CommonMark\Renderer\NodeRendererInterface; 11 | use League\CommonMark\Xml\XmlNodeRendererInterface; 12 | use League\Config\ConfigurationAwareInterface; 13 | use League\Config\ConfigurationInterface; 14 | use Tempest\Support\Regex; 15 | 16 | final class LinkRenderer implements NodeRendererInterface, XmlNodeRendererInterface, ConfigurationAwareInterface 17 | { 18 | private ConfigurationInterface $config; 19 | 20 | #[\Override] 21 | public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable 22 | { 23 | if (! ($node instanceof Link)) { 24 | throw new InvalidArgumentException('Node must be instance of ' . Link::class); 25 | } 26 | 27 | // Replace .md at the end, before a / or a # 28 | $node->setUrl( 29 | Regex\replace($node->getUrl(), '/\.md((?=[\/#?])|$)/', ''), 30 | ); 31 | 32 | $renderer = new InlineLinkRenderer(); 33 | $renderer->setConfiguration($this->config); 34 | 35 | return $renderer->render($node, $childRenderer); 36 | } 37 | 38 | #[\Override] 39 | public function setConfiguration(ConfigurationInterface $configuration): void 40 | { 41 | $this->config = $configuration; 42 | } 43 | 44 | #[\Override] 45 | public function getXmlTagName(Node $node): string 46 | { 47 | return 'link'; 48 | } 49 | 50 | #[\Override] 51 | public function getXmlAttributes(Node $node): array 52 | { 53 | if (! ($node instanceof Link)) { 54 | throw new InvalidArgumentException('Node must be instance of ' . Link::class); 55 | } 56 | 57 | return [ 58 | 'destination' => $node->getUrl(), 59 | 'title' => $node->getTitle() ?? '', 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Markdown/MarkdownInitializer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace App\Markdown; 6 | 7 | use App\Markdown\Alerts\AlertExtension; 8 | use App\Markdown\CodeBlockRenderer; 9 | use App\Markdown\Symbols\AttributeParser; 10 | use App\Markdown\Symbols\FqcnParser; 11 | use League\CommonMark\Environment\Environment; 12 | use League\CommonMark\Extension\Attributes\AttributesExtension; 13 | use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; 14 | use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; 15 | use League\CommonMark\Extension\CommonMark\Node\Block\Heading; 16 | use League\CommonMark\Extension\CommonMark\Node\Inline\Code; 17 | use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 18 | use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; 19 | use League\CommonMark\MarkdownConverter; 20 | use Tempest\Container\Container; 21 | use Tempest\Container\Initializer; 22 | use Tempest\Container\Singleton; 23 | use Tempest\Highlight\Highlighter; 24 | 25 | final readonly class MarkdownInitializer implements Initializer 26 | { 27 | #[\Override] 28 | #[Singleton] 29 | public function initialize(Container $container): MarkdownConverter 30 | { 31 | $environment = new Environment(); 32 | $highlighter = $container->get(Highlighter::class, tag: 'project'); 33 | 34 | $environment 35 | ->addExtension(new CommonMarkCoreExtension()) 36 | ->addExtension(new FrontMatterExtension()) 37 | ->addExtension(new AttributesExtension()) 38 | ->addExtension(new AlertExtension()) 39 | ->addInlineParser(new TempestPackageParser()) 40 | ->addInlineParser(new FqcnParser()) 41 | ->addInlineParser(new AttributeParser()) 42 | ->addInlineParser(new HandleParser()) 43 | ->addRenderer(FencedCode::class, new CodeBlockRenderer($highlighter)) 44 | ->addRenderer(Code::class, new InlineCodeBlockRenderer($highlighter)) 45 | ->addRenderer(Link::class, new LinkRenderer()) 46 | ->addRenderer(Heading::class, new HeadingRenderer()); 47 | 48 | return new MarkdownConverter($environment); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Markdown/Symbols/AttributeParser.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown\Symbols; 4 | 5 | use League\CommonMark\Extension\CommonMark\Node\Inline\Code; 6 | use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 7 | use League\CommonMark\Parser\Inline\InlineParserInterface; 8 | use League\CommonMark\Parser\Inline\InlineParserMatch; 9 | use League\CommonMark\Parser\InlineParserContext; 10 | use Tempest\Support\Str; 11 | 12 | use function Tempest\Support\str; 13 | 14 | final readonly class AttributeParser implements InlineParserInterface 15 | { 16 | #[\Override] 17 | public function getMatchDefinition(): InlineParserMatch 18 | { 19 | return InlineParserMatch::regex("{(b)?`#\[((?:\\\{1,2}\w+|\w+\\\{1,2})(?:\w+\\\{0,2})+)\]`}"); 20 | } 21 | 22 | #[\Override] 23 | public function parse(InlineParserContext $inlineContext): bool 24 | { 25 | $cursor = $inlineContext->getCursor(); 26 | $previousChar = $cursor->peek(-1); 27 | 28 | if ($previousChar !== null && $previousChar !== ' ') { 29 | return false; 30 | } 31 | 32 | $cursor->advanceBy($inlineContext->getFullMatchLength()); 33 | 34 | [$flag, $fqcn] = $inlineContext->getSubMatches(); 35 | $url = str($fqcn) 36 | ->stripStart(['\\Tempest\\', 'Tempest\\']) 37 | ->replaceRegex("/^(\w+)/", fn (array $matches) => sprintf('packages/%s/src', Str\to_kebab_case($matches[0]))) 38 | ->replaceEvery(['date-time' => 'datetime']) 39 | ->replace('\\', '/') 40 | ->prepend('https://github.com/tempestphp/tempest-framework/blob/main/') 41 | ->append('.php'); 42 | 43 | $attribute = str($fqcn) 44 | ->stripStart('\\') 45 | ->when($flag === 'b', fn ($s) => $s->classBasename()) 46 | ->wrap(before: '#[', after: ']') 47 | ->toString(); 48 | 49 | $link = new Link($url); 50 | $link->appendChild(new Code($attribute)); 51 | $inlineContext->getContainer()->appendChild($link); 52 | 53 | return true; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Markdown/Symbols/FqcnParser.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown\Symbols; 4 | 5 | use League\CommonMark\Extension\CommonMark\Node\Inline\Code; 6 | use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 7 | use League\CommonMark\Parser\Inline\InlineParserInterface; 8 | use League\CommonMark\Parser\Inline\InlineParserMatch; 9 | use League\CommonMark\Parser\InlineParserContext; 10 | use Tempest\Support\Str; 11 | 12 | use function Tempest\Support\str; 13 | 14 | final readonly class FqcnParser implements InlineParserInterface 15 | { 16 | #[\Override] 17 | public function getMatchDefinition(): InlineParserMatch 18 | { 19 | return InlineParserMatch::regex("{(b)?`((?:\\\{1,2}\w+|\w+\\\{1,2})(?:\w+\\\{0,2})+)`}"); 20 | } 21 | 22 | #[\Override] 23 | public function parse(InlineParserContext $inlineContext): bool 24 | { 25 | $cursor = $inlineContext->getCursor(); 26 | $previousChar = $cursor->peek(-1); 27 | 28 | if ($previousChar !== null && $previousChar !== ' ') { 29 | return false; 30 | } 31 | 32 | $cursor->advanceBy($inlineContext->getFullMatchLength()); 33 | 34 | [$flag, $fqcn] = $inlineContext->getSubMatches(); 35 | $url = str($fqcn) 36 | ->stripStart(['\\Tempest\\', 'Tempest\\']) 37 | ->replaceRegex("/^(\w+)/", fn (array $matches) => sprintf('packages/%s/src', Str\to_kebab_case($matches[0]))) 38 | ->replaceEvery(['date-time' => 'datetime']) 39 | ->replace('\\', '/') 40 | ->prepend('https://github.com/tempestphp/tempest-framework/blob/main/') 41 | ->append('.php'); 42 | 43 | $link = new Link($url); 44 | $link->appendChild(new Code($flag === 'b' ? Str\class_basename($fqcn) : Str\strip_start($fqcn, '\\'))); 45 | $inlineContext->getContainer()->appendChild($link); 46 | 47 | return true; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Markdown/TempestPackageParser.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Markdown; 4 | 5 | use League\CommonMark\Extension\CommonMark\Node\Inline\Code; 6 | use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 7 | use League\CommonMark\Parser\Inline\InlineParserInterface; 8 | use League\CommonMark\Parser\Inline\InlineParserMatch; 9 | use League\CommonMark\Parser\InlineParserContext; 10 | 11 | use function Tempest\Support\str; 12 | 13 | final readonly class TempestPackageParser implements InlineParserInterface 14 | { 15 | #[\Override] 16 | public function getMatchDefinition(): InlineParserMatch 17 | { 18 | return InlineParserMatch::regex("{`tempest\\/([\w-]+)`}"); 19 | } 20 | 21 | #[\Override] 22 | public function parse(InlineParserContext $inlineContext): bool 23 | { 24 | $cursor = $inlineContext->getCursor(); 25 | $previousChar = $cursor->peek(-1); 26 | 27 | if ($previousChar !== null && $previousChar !== ' ') { 28 | return false; 29 | } 30 | 31 | $cursor->advanceBy($inlineContext->getFullMatchLength()); 32 | 33 | [$package] = $inlineContext->getSubMatches(); 34 | 35 | $url = match ($package) { 36 | 'app' => 'https://github.com/tempestphp/tempest-app', 37 | default => $url = str($package) 38 | ->kebab() 39 | ->prepend('https://github.com/tempestphp/tempest-framework/tree/main/packages/') 40 | ->toString(), 41 | }; 42 | 43 | $link = new Link($url); 44 | $link->appendChild(new Code("tempest/$package")); 45 | $inlineContext->getContainer()->appendChild($link); 46 | 47 | return true; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Migrations/FixDateTimeFieldsMigration.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Migrations; 4 | 5 | use App\Web\Analytics\VisitsPerDay\VisitsPerDay; 6 | use App\Web\Analytics\VisitsPerHour\VisitsPerHour; 7 | use Tempest\Console\ConsoleCommand; 8 | use Tempest\Console\HasConsole; 9 | 10 | final class FixDateTimeFieldsMigration 11 | { 12 | use HasConsole; 13 | 14 | #[ConsoleCommand(name: 'fix:projections')] 15 | public function __invoke(): void 16 | { 17 | foreach (VisitsPerDay::all() as $visitsPerDay) { 18 | $visitsPerDay->save(); 19 | } 20 | 21 | foreach (VisitsPerHour::all() as $visitsPerDay) { 22 | $visitsPerDay->save(); 23 | } 24 | 25 | $this->success('Done'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/StoredEvents/CreateStoredEventTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | use Tempest\Database\DatabaseMigration; 6 | use Tempest\Database\QueryStatement; 7 | use Tempest\Database\QueryStatements\CreateTableStatement; 8 | 9 | final class CreateStoredEventTable implements DatabaseMigration 10 | { 11 | public string $name { 12 | get => '00-00-0000-create_stored_events_table'; 13 | } 14 | 15 | #[\Override] 16 | public function up(): ?QueryStatement 17 | { 18 | return CreateTableStatement::forModel(StoredEvent::class) 19 | ->primary() 20 | ->text('uuid') 21 | ->text('eventClass') 22 | ->text('payload') 23 | ->datetime('createdAt'); 24 | } 25 | 26 | #[\Override] 27 | public function down(): ?QueryStatement 28 | { 29 | // @mago-expect comment/no-untagged-todo 30 | // TODO: Implement down() method. 31 | return null; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/StoredEvents/EventsReplayCommand.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | use Tempest\Console\Console; 6 | use Tempest\Console\ConsoleCommand; 7 | use Tempest\Console\HasConsole; 8 | use Tempest\Console\Middleware\ForceMiddleware; 9 | use Tempest\Container\Container; 10 | use Tempest\Database\Builder\QueryBuilders\QueryBuilder; 11 | 12 | use function Tempest\Support\arr; 13 | use function Tempest\Support\str; 14 | 15 | final readonly class EventsReplayCommand 16 | { 17 | use HasConsole; 18 | 19 | public function __construct( 20 | private StoredEventConfig $storedEventConfig, 21 | private Console $console, 22 | private Container $container, 23 | ) { 24 | } 25 | 26 | #[ConsoleCommand(middleware: [ForceMiddleware::class])] 27 | public function __invoke(?string $replay = null): void 28 | { 29 | $projectors = arr($this->storedEventConfig->projectors)->sort(); 30 | 31 | if ($replay) { 32 | $replay = [$replay]; 33 | } else { 34 | $replay = $this->ask( 35 | question: 'Which projects should be replayed?', 36 | options: $projectors->toArray(), 37 | multiple: true, 38 | ); 39 | } 40 | 41 | $replayCount = count($replay); 42 | 43 | if (! $replayCount) { 44 | $this->error('No projectors selected'); 45 | 46 | return; 47 | } 48 | 49 | $eventCount = new QueryBuilder(StoredEvent::class) 50 | ->select('COUNT(*) as `count`') 51 | ->first()['count']; 52 | 53 | $confirm = $this->confirm(sprintf( 54 | 'We\'re going to replay %d events on %d %s, this will take a while. Continue?', 55 | $eventCount, 56 | $replayCount, 57 | str('projector')->pluralize($replayCount), 58 | )); 59 | 60 | if (! $confirm) { 61 | $this->error('Cancelled'); 62 | 63 | return; 64 | } 65 | 66 | foreach ($projectors as $projectorClass) { 67 | if (! in_array($projectorClass, $replay, strict: true)) { 68 | continue; 69 | } 70 | 71 | $this->info(sprintf('Replaying <style="underline">%s</style>', $projectorClass)); 72 | 73 | /** @var \App\StoredEvents\Projector $projector */ 74 | $projector = $this->container->get($projectorClass); 75 | 76 | $projector->clear(); 77 | 78 | StoredEvent::select() 79 | ->orderBy('createdAt ASC') 80 | ->chunk( 81 | function (array $storedEvents) use ($projector) { 82 | $this->write('.'); 83 | 84 | foreach ($storedEvents as $storedEvent) { 85 | $projector->replay($storedEvent->getEvent()); 86 | } 87 | }, 88 | 500, 89 | ); 90 | 91 | $this->writeln(); 92 | } 93 | 94 | $this->success('Done'); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/StoredEvents/HasCreatedAtDate.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | use DateTimeImmutable; 6 | 7 | interface HasCreatedAtDate 8 | { 9 | public DateTimeImmutable $createdAt { 10 | get; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/StoredEvents/ProjectionDiscovery.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | use Tempest\Discovery\Discovery; 6 | use Tempest\Discovery\DiscoveryLocation; 7 | use Tempest\Discovery\IsDiscovery; 8 | use Tempest\Reflection\ClassReflector; 9 | 10 | final class ProjectionDiscovery implements Discovery 11 | { 12 | use IsDiscovery; 13 | 14 | public function __construct( 15 | private readonly StoredEventConfig $config, 16 | ) { 17 | } 18 | 19 | #[\Override] 20 | public function discover(DiscoveryLocation $location, ClassReflector $class): void 21 | { 22 | if ($class->implements(Projector::class)) { 23 | $this->discoveryItems->add($location, $class->getName()); 24 | } 25 | } 26 | 27 | #[\Override] 28 | public function apply(): void 29 | { 30 | foreach ($this->discoveryItems as $className) { 31 | $this->config->projectors[] = $className; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/StoredEvents/Projector.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | interface Projector 6 | { 7 | public function replay(object $event): void; 8 | 9 | public function clear(): void; 10 | } 11 | -------------------------------------------------------------------------------- /src/StoredEvents/ShouldBeStored.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | interface ShouldBeStored 6 | { 7 | public string $uuid { 8 | get; 9 | } 10 | 11 | public function serialize(): string; 12 | 13 | public static function unserialize(string $payload): self; 14 | } 15 | -------------------------------------------------------------------------------- /src/StoredEvents/StoredEvent.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | use DateTimeImmutable; 6 | use Tempest\Database\IsDatabaseModel; 7 | use Tempest\Reflection\ClassReflector; 8 | 9 | final class StoredEvent 10 | { 11 | use IsDatabaseModel; 12 | 13 | public function __construct( 14 | public string $uuid, 15 | public string $eventClass, 16 | public string $payload, 17 | public DateTimeImmutable $createdAt = new DateTimeImmutable(), 18 | ) { 19 | } 20 | 21 | public function getEvent(): object 22 | { 23 | return new ClassReflector($this->eventClass)->callStatic('unserialize', $this->payload); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/StoredEvents/StoredEventConfig.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | final class StoredEventConfig 6 | { 7 | public function __construct( 8 | /** @var class-string<\App\StoredEvents\Projector> $projectors */ 9 | public array $projectors = [], 10 | ) { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/StoredEvents/StoredEventMiddleware.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\StoredEvents; 4 | 5 | use DateTimeImmutable; 6 | use Tempest\EventBus\EventBusMiddleware; 7 | use Tempest\EventBus\EventBusMiddlewareCallable; 8 | 9 | final readonly class StoredEventMiddleware implements EventBusMiddleware 10 | { 11 | #[\Override] 12 | public function __invoke(string|object $event, EventBusMiddlewareCallable $next): void 13 | { 14 | if ($event instanceof ShouldBeStored) { 15 | new StoredEvent( 16 | uuid: $event->uuid, 17 | eventClass: $event::class, 18 | payload: $event->serialize(), 19 | createdAt: ($event instanceof HasCreatedAtDate) ? $event->createdAt : new DateTimeImmutable(), 20 | )->save(); 21 | } 22 | 23 | $next($event); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Web/Analytics/AnalyticsConfig.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics; 4 | 5 | final class AnalyticsConfig 6 | { 7 | public function __construct( 8 | private(set) string $accessLogPath, 9 | ) { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Web/Analytics/Chart.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics; 4 | 5 | use Tempest\Support\Arr\ImmutableArray; 6 | 7 | final readonly class Chart 8 | { 9 | public function __construct( 10 | public ImmutableArray $labels, 11 | public ImmutableArray $values, 12 | ) { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsListed.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics; 4 | 5 | use App\StoredEvents\ShouldBeStored; 6 | use DateTimeImmutable; 7 | use Symfony\Component\Uid\Uuid; 8 | 9 | final class PackageDownloadsListed implements ShouldBeStored 10 | { 11 | public string $uuid; 12 | 13 | public function __construct( 14 | public string $package, 15 | public DateTimeImmutable $date, 16 | public int $monthly, 17 | public int $daily, 18 | public int $total, 19 | ) { 20 | $this->uuid = Uuid::v4()->toString(); 21 | } 22 | 23 | #[\Override] 24 | public function serialize(): string 25 | { 26 | return json_encode([ 27 | 'uuid' => $this->uuid, 28 | 'date' => $this->date->format('c'), 29 | 'package' => $this->package, 30 | 'monthly' => $this->monthly, 31 | 'daily' => $this->daily, 32 | 'total' => $this->total, 33 | ]); 34 | } 35 | 36 | #[\Override] 37 | public static function unserialize(string $payload): self 38 | { 39 | $data = json_decode($payload, true); 40 | 41 | $self = new self( 42 | package: $data['package'], 43 | date: new DateTimeImmutable($data['date']), 44 | monthly: $data['monthly'], 45 | daily: $data['daily'], 46 | total: $data['total'], 47 | ); 48 | 49 | $self->uuid = $data['uuid']; 50 | 51 | return $self; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerDay/CreatePackageDownloadsPerDayTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerDay; 4 | 5 | use Tempest\Database\DatabaseMigration; 6 | use Tempest\Database\QueryStatement; 7 | use Tempest\Database\QueryStatements\CreateTableStatement; 8 | use Tempest\Database\QueryStatements\DropTableStatement; 9 | 10 | final class CreatePackageDownloadsPerDayTable implements DatabaseMigration 11 | { 12 | public string $name = '2024-12-14_01_create_package_downloads_per_day_table'; 13 | 14 | #[\Override] 15 | public function up(): ?QueryStatement 16 | { 17 | return CreateTableStatement::forModel(PackageDownloadsPerDay::class) 18 | ->primary() 19 | ->datetime('date') 20 | ->varchar('package') 21 | ->integer('count') 22 | ->unique('date', 'package'); 23 | } 24 | 25 | #[\Override] 26 | public function down(): ?QueryStatement 27 | { 28 | return DropTableStatement::forModel(PackageDownloadsPerDay::class); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerDay/PackageDownloadsPerDay.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerDay; 4 | 5 | use DateTimeImmutable; 6 | use Tempest\Database\IsDatabaseModel; 7 | 8 | final class PackageDownloadsPerDay 9 | { 10 | use IsDatabaseModel; 11 | 12 | public function __construct( 13 | public DateTimeImmutable $date, 14 | public string $package, 15 | public int $count, 16 | public int $total, 17 | ) { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerDay/PackageDownloadsPerDayProjector.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerDay; 4 | 5 | use App\StoredEvents\Projector; 6 | use App\Web\Analytics\PackageDownloadsListed; 7 | use Tempest\Database\Builder\QueryBuilders\QueryBuilder; 8 | use Tempest\Database\Query; 9 | use Tempest\EventBus\EventHandler; 10 | 11 | final readonly class PackageDownloadsPerDayProjector implements Projector 12 | { 13 | #[\Override] 14 | public function clear(): void 15 | { 16 | new QueryBuilder(PackageDownloadsPerDay::class) 17 | ->delete() 18 | ->execute(); 19 | } 20 | 21 | #[\Override] 22 | public function replay(object $event): void 23 | { 24 | if ($event instanceof PackageDownloadsListed) { 25 | $this->onPackageDownloadsListed($event); 26 | } 27 | } 28 | 29 | #[EventHandler] 30 | public function onPackageDownloadsListed(PackageDownloadsListed $event): void 31 | { 32 | $count = $event->total; 33 | 34 | $count = max($count, 0); 35 | 36 | PackageDownloadsPerDay::updateOrCreate( 37 | [ 38 | 'date' => $event->date, 39 | 'package' => $event->package, 40 | ], 41 | [ 42 | 'total' => $event->total, 43 | 'count' => $count, 44 | ], 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerDay/UpdatePackageDownloadsPerDayTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerDay; 4 | 5 | use App\Web\Analytics\PackageDownloadsPerHour\PackageDownloadsPerHour; 6 | use Tempest\Database\DatabaseMigration; 7 | use Tempest\Database\QueryStatement; 8 | use Tempest\Database\QueryStatements\AlterTableStatement; 9 | use Tempest\Database\QueryStatements\IntegerStatement; 10 | 11 | final class UpdatePackageDownloadsPerDayTable implements DatabaseMigration 12 | { 13 | public string $name = '2024-12-14_03_update_package_downloads_per_day_table'; 14 | 15 | #[\Override] 16 | public function up(): ?QueryStatement 17 | { 18 | return AlterTableStatement::forModel(PackageDownloadsPerDay::class) 19 | ->add(new IntegerStatement('total', default: 0)); 20 | } 21 | 22 | #[\Override] 23 | public function down(): ?QueryStatement 24 | { 25 | return null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerHour/CreatePackageDownloadsPerHourTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerHour; 4 | 5 | use Tempest\Database\DatabaseMigration; 6 | use Tempest\Database\QueryStatement; 7 | use Tempest\Database\QueryStatements\CreateTableStatement; 8 | use Tempest\Database\QueryStatements\DropTableStatement; 9 | 10 | final class CreatePackageDownloadsPerHourTable implements DatabaseMigration 11 | { 12 | public string $name = '2024-12-14_01_create_package_downloads_per_hour_table'; 13 | 14 | #[\Override] 15 | public function up(): ?QueryStatement 16 | { 17 | return CreateTableStatement::forModel(PackageDownloadsPerHour::class) 18 | ->primary() 19 | ->datetime('date') 20 | ->varchar('package') 21 | ->integer('count') 22 | ->unique('date', 'package'); 23 | } 24 | 25 | #[\Override] 26 | public function down(): ?QueryStatement 27 | { 28 | return DropTableStatement::forModel(PackageDownloadsPerHour::class); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerHour/PackageDownloadsPerHour.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerHour; 4 | 5 | use DateTimeImmutable; 6 | use Tempest\Database\IsDatabaseModel; 7 | 8 | final class PackageDownloadsPerHour 9 | { 10 | use IsDatabaseModel; 11 | 12 | public function __construct( 13 | public DateTimeImmutable $date, 14 | public string $package, 15 | public int $count, 16 | public int $total, 17 | ) { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerHour/PackageDownloadsPerHourProjector.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerHour; 4 | 5 | use App\StoredEvents\Projector; 6 | use App\Web\Analytics\PackageDownloadsListed; 7 | use Tempest\Database\Builder\QueryBuilders\QueryBuilder; 8 | use Tempest\Database\Query; 9 | use Tempest\EventBus\EventHandler; 10 | 11 | final readonly class PackageDownloadsPerHourProjector implements Projector 12 | { 13 | #[\Override] 14 | public function clear(): void 15 | { 16 | new QueryBuilder(PackageDownloadsPerHour::class) 17 | ->delete() 18 | ->execute(); 19 | } 20 | 21 | #[\Override] 22 | public function replay(object $event): void 23 | { 24 | if ($event instanceof PackageDownloadsListed) { 25 | $this->onPackageDownloadsListed($event); 26 | } 27 | } 28 | 29 | #[EventHandler] 30 | public function onPackageDownloadsListed(PackageDownloadsListed $event): void 31 | { 32 | $count = $event->total; 33 | 34 | $count = max($count, 0); 35 | 36 | $date = $event->date->setTime($event->date->format('H'), 0, 0); 37 | 38 | PackageDownloadsPerHour::updateOrCreate( 39 | [ 40 | 'date' => $date->format(DATE_ATOM), 41 | 'package' => $event->package, 42 | ], 43 | [ 44 | 'total' => $event->total, 45 | 'count' => $count, 46 | ], 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Web/Analytics/PackageDownloadsPerHour/UpdatePackageDownloadsPerHourTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\PackageDownloadsPerHour; 4 | 5 | use Tempest\Database\DatabaseMigration; 6 | use Tempest\Database\QueryStatement; 7 | use Tempest\Database\QueryStatements\AlterTableStatement; 8 | use Tempest\Database\QueryStatements\IntegerStatement; 9 | 10 | final class UpdatePackageDownloadsPerHourTable implements DatabaseMigration 11 | { 12 | public string $name = '2024-12-14_02_update_package_downloads_per_hour_table'; 13 | 14 | #[\Override] 15 | public function up(): ?QueryStatement 16 | { 17 | return AlterTableStatement::forModel(PackageDownloadsPerHour::class) 18 | ->add(new IntegerStatement('total', default: 0)); 19 | } 20 | 21 | #[\Override] 22 | public function down(): ?QueryStatement 23 | { 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Web/Analytics/PageVisited.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics; 4 | 5 | use App\StoredEvents\HasCreatedAtDate; 6 | use App\StoredEvents\ShouldBeStored; 7 | use DateTimeImmutable; 8 | use Symfony\Component\Uid\Uuid; 9 | 10 | final class PageVisited implements ShouldBeStored, HasCreatedAtDate 11 | { 12 | public string $uuid; 13 | 14 | public function __construct( 15 | public string $url, 16 | public DateTimeImmutable $visitedAt, 17 | public string $ip, 18 | public string $userAgent, 19 | public string $raw, 20 | ) { 21 | $this->uuid = Uuid::v4()->toString(); 22 | } 23 | 24 | public DateTimeImmutable $createdAt { 25 | get => $this->visitedAt; 26 | } 27 | 28 | #[\Override] 29 | public function serialize(): string 30 | { 31 | return json_encode([ 32 | 'uuid' => $this->uuid, 33 | 'url' => $this->url, 34 | 'visitedAt' => $this->visitedAt->format('c'), 35 | 'ip' => $this->ip, 36 | 'userAgent' => $this->userAgent, 37 | 'raw' => $this->raw, 38 | 'uri' => $this->url, 39 | ]); 40 | } 41 | 42 | #[\Override] 43 | public static function unserialize(string $payload): self 44 | { 45 | $data = json_decode($payload, true); 46 | 47 | $self = new self( 48 | url: $data['url'], 49 | visitedAt: new DateTimeImmutable($data['visitedAt']), 50 | ip: $data['ip'], 51 | userAgent: $data['userAgent'], 52 | raw: $data['raw'], 53 | ); 54 | 55 | $self->uuid = $data['uuid']; 56 | 57 | return $self; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Web/Analytics/ParsePackagistCommand.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics; 4 | 5 | use DateTimeImmutable; 6 | use Tempest\Cache\Cache; 7 | use Tempest\Console\ConsoleCommand; 8 | use Tempest\Console\HasConsole; 9 | use Tempest\Console\Schedule; 10 | use Tempest\Console\Scheduler\Every; 11 | use Tempest\DateTime\Duration; 12 | use Tempest\HttpClient\HttpClient; 13 | use Throwable; 14 | 15 | use function Tempest\event; 16 | 17 | final readonly class ParsePackagistCommand 18 | { 19 | use HasConsole; 20 | 21 | public function __construct( 22 | private HttpClient $httpClient, 23 | private Cache $cache, 24 | ) { 25 | } 26 | 27 | #[Schedule(Every::HOUR)] 28 | #[ConsoleCommand] 29 | public function __invoke(): void 30 | { 31 | $packages = [ 32 | 'framework', 33 | 'app', 34 | 'app-console', 35 | 'cache', 36 | 'console', 37 | 'clock', 38 | 'highlight', 39 | 'command-bus', 40 | 'core', 41 | 'database', 42 | 'debug', 43 | 'http', 44 | 'http-client', 45 | 'log', 46 | 'mapper', 47 | 'reflection', 48 | 'router', 49 | 'support', 50 | 'validation', 51 | 'view', 52 | ]; 53 | 54 | foreach ($packages as $package) { 55 | $url = "https://packagist.org/packages/tempest/{$package}.json"; 56 | 57 | try { 58 | $data = $this->cache->resolve( 59 | key: "packagist-{$package}", 60 | callback: fn () => json_decode($this->httpClient->get($url)->body, associative: true), 61 | expiration: Duration::minutes(30), 62 | ); 63 | 64 | event(new PackageDownloadsListed( 65 | package: $package, 66 | date: new DateTimeImmutable(), 67 | monthly: $data['package']['downloads']['monthly'] ?? null, 68 | daily: $data['package']['downloads']['daily'] ?? null, 69 | total: $data['package']['downloads']['total'] ?? null, 70 | )); 71 | 72 | $this->success("tempest/{$package}"); 73 | } catch (Throwable $e) { 74 | $this->error("tempest/{$package}"); 75 | $this->writeln($e->getMessage()); 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Web/Analytics/StatsController.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics; 4 | 5 | use App\Web\Analytics\PackageDownloadsPerHour\PackageDownloadsPerHour; 6 | use App\Web\Analytics\VisitsPerDay\VisitsPerDay; 7 | use App\Web\Analytics\VisitsPerHour\VisitsPerHour; 8 | use DateInterval; 9 | use DateTimeImmutable; 10 | use Tempest\Clock\Clock; 11 | use Tempest\Database\Query; 12 | use Tempest\DateTime\Duration; 13 | use Tempest\Router\Get; 14 | use Tempest\View\View; 15 | 16 | use function Tempest\Support\arr; 17 | use function Tempest\view; 18 | 19 | final readonly class StatsController 20 | { 21 | #[Get('/stats')] 22 | public function __invoke(Clock $clock): View 23 | { 24 | $now = $clock->now(); 25 | 26 | $visitsPerHour = arr( 27 | VisitsPerHour::select() 28 | ->where('date >= ?', $now->minus(Duration::hours(24))->format('Y-m-d H:i:s')) 29 | ->all(), 30 | ); 31 | 32 | $visitsPerDay = arr( 33 | VisitsPerDay::select() 34 | ->where('date >= ?', $now->minus(Duration::days(30))->format('Y-m-d H:i:s')) 35 | ->all(), 36 | ); 37 | 38 | $packageDownloadsPerHour = arr(new Query(<<<SQL 39 | SELECT `date`, SUM(`count`) as `count` FROM package_downloads_per_hours WHERE `date` >= :date GROUP BY `date` 40 | SQL)->fetch( 41 | date: $now->minus(Duration::days(24))->format('Y-m-d H:i:s'), 42 | )); 43 | 44 | $packageDownloadsPerDay = arr(new Query(<<<SQL 45 | SELECT `date`, SUM(`count`) as `count` FROM package_downloads_per_days WHERE date >= :date GROUP BY `date` 46 | SQL)->fetch( 47 | date: $now->minus(Duration::days(30))->format('Y-m-d H:i:s'), 48 | )); 49 | 50 | return view( 51 | __DIR__ . '/stats.view.php', 52 | 53 | visitsPerHour: new Chart( 54 | labels: $visitsPerHour->map(fn (VisitsPerHour $item) => $item->date->format('H:i')), 55 | values: $visitsPerHour->map(fn (VisitsPerHour $item) => $item->count), 56 | ), 57 | 58 | visitsPerDay: new Chart( 59 | labels: $visitsPerDay->map(fn (VisitsPerDay $item) => $item->date->format('Y-m-d')), 60 | values: $visitsPerDay->map(fn (VisitsPerDay $item) => $item->count), 61 | ), 62 | 63 | packageDownloadsPerHour: new Chart( 64 | labels: $packageDownloadsPerHour->map(fn (array $item) => new DateTimeImmutable($item['date'])->format('H:i')), 65 | values: $packageDownloadsPerHour->map(fn (array $item) => $item['count']), 66 | ), 67 | 68 | packageDownloadsPerDay: new Chart( 69 | labels: $packageDownloadsPerDay->map(fn (array $item) => new DateTimeImmutable($item['date'])->format('Y-m-d')), 70 | values: $packageDownloadsPerDay->map(fn (array $item) => $item['count']), 71 | ), 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Web/Analytics/VisitsPerDay/AlterVisitsPerDayTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\VisitsPerDay; 4 | 5 | use Tempest\Database\DatabaseMigration; 6 | use Tempest\Database\QueryStatement; 7 | use Tempest\Database\QueryStatements\AlterTableStatement; 8 | 9 | final class AlterVisitsPerDayTable implements DatabaseMigration 10 | { 11 | public string $name = '2024-12-13_01_alter_visits_table'; 12 | 13 | #[\Override] 14 | public function up(): ?QueryStatement 15 | { 16 | return AlterTableStatement::forModel(VisitsPerDay::class)->unique('date'); 17 | } 18 | 19 | #[\Override] 20 | public function down(): ?QueryStatement 21 | { 22 | return null; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Web/Analytics/VisitsPerDay/CreateVisitsPerDayTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\VisitsPerDay; 4 | 5 | use Tempest\Database\DatabaseMigration; 6 | use Tempest\Database\QueryStatement; 7 | use Tempest\Database\QueryStatements\CreateTableStatement; 8 | use Tempest\Database\QueryStatements\DropTableStatement; 9 | 10 | final class CreateVisitsPerDayTable implements DatabaseMigration 11 | { 12 | public string $name = '2024-12-12_01_create_visits_table'; 13 | 14 | #[\Override] 15 | public function up(): ?QueryStatement 16 | { 17 | return CreateTableStatement::forModel(VisitsPerDay::class) 18 | ->primary() 19 | ->text('date') 20 | ->integer('count'); 21 | } 22 | 23 | #[\Override] 24 | public function down(): ?QueryStatement 25 | { 26 | return DropTableStatement::forModel(VisitsPerDay::class); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Web/Analytics/VisitsPerDay/VisitsPerDay.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\VisitsPerDay; 4 | 5 | use DateTimeImmutable; 6 | use Tempest\Database\IsDatabaseModel; 7 | 8 | final class VisitsPerDay 9 | { 10 | use IsDatabaseModel; 11 | 12 | public function __construct( 13 | private(set) DateTimeImmutable $date, 14 | private(set) int $count, 15 | ) { 16 | } 17 | 18 | public function increment(): self 19 | { 20 | $this->count += 1; 21 | 22 | return $this; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Web/Analytics/VisitsPerDay/VisitsPerDayProjector.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\VisitsPerDay; 4 | 5 | use App\StoredEvents\Projector; 6 | use App\Web\Analytics\PageVisited; 7 | use Tempest\Database\Builder\QueryBuilders\QueryBuilder; 8 | use Tempest\EventBus\EventHandler; 9 | 10 | final readonly class VisitsPerDayProjector implements Projector 11 | { 12 | #[\Override] 13 | public function replay(object $event): void 14 | { 15 | if ($event instanceof PageVisited) { 16 | $this->onPageVisited($event); 17 | } 18 | } 19 | 20 | #[\Override] 21 | public function clear(): void 22 | { 23 | new QueryBuilder(VisitsPerDay::class) 24 | ->delete() 25 | ->execute(); 26 | } 27 | 28 | #[EventHandler] 29 | public function onPageVisited(PageVisited $pageVisited): void 30 | { 31 | $visitedAt = $pageVisited->visitedAt->setTime(0, 0); 32 | 33 | $day = VisitsPerDay::select() 34 | ->whereField('date', $visitedAt->format('Y-m-d H:i:s')) 35 | ->first(); 36 | 37 | if (! $day) { 38 | $day = new VisitsPerDay( 39 | date: $visitedAt, 40 | count: 0, 41 | ); 42 | } 43 | 44 | $day->increment()->save(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Web/Analytics/VisitsPerHour/CreateVisitsPerHourTable.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\VisitsPerHour; 4 | 5 | use Tempest\Database\DatabaseMigration; 6 | use Tempest\Database\QueryStatement; 7 | use Tempest\Database\QueryStatements\CreateTableStatement; 8 | use Tempest\Database\QueryStatements\DropTableStatement; 9 | 10 | final class CreateVisitsPerHourTable implements DatabaseMigration 11 | { 12 | public string $name = '2024-12-13_01_create_visits_per_hour_table'; 13 | 14 | #[\Override] 15 | public function up(): ?QueryStatement 16 | { 17 | return CreateTableStatement::forModel(VisitsPerHour::class) 18 | ->primary() 19 | ->datetime('date') 20 | ->integer('count') 21 | ->index('date'); 22 | } 23 | 24 | #[\Override] 25 | public function down(): ?QueryStatement 26 | { 27 | return DropTableStatement::forModel(VisitsPerHour::class); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Web/Analytics/VisitsPerHour/VisitsPerHour.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\VisitsPerHour; 4 | 5 | use DateTimeImmutable; 6 | use Tempest\Database\IsDatabaseModel; 7 | 8 | final class VisitsPerHour 9 | { 10 | use IsDatabaseModel; 11 | 12 | public function __construct( 13 | private(set) DateTimeImmutable $date, 14 | private(set) int $count, 15 | ) { 16 | } 17 | 18 | public function increment(): self 19 | { 20 | $this->count += 1; 21 | 22 | return $this; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Web/Analytics/VisitsPerHour/VisitsPerHourProjector.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Analytics\VisitsPerHour; 4 | 5 | use App\StoredEvents\Projector; 6 | use App\Web\Analytics\PageVisited; 7 | use Tempest\Database\Builder\QueryBuilders\QueryBuilder; 8 | use Tempest\Database\Query; 9 | use Tempest\EventBus\EventHandler; 10 | 11 | final readonly class VisitsPerHourProjector implements Projector 12 | { 13 | #[\Override] 14 | public function replay(object $event): void 15 | { 16 | if ($event instanceof PageVisited) { 17 | $this->onPageVisited($event); 18 | } 19 | } 20 | 21 | #[\Override] 22 | public function clear(): void 23 | { 24 | new QueryBuilder(VisitsPerHour::class) 25 | ->delete() 26 | ->execute(); 27 | } 28 | 29 | #[EventHandler] 30 | public function onPageVisited(PageVisited $pageVisited): void 31 | { 32 | $visitedAt = $pageVisited->visitedAt->setTime( 33 | hour: $pageVisited->visitedAt->format('H'), 34 | minute: 0, 35 | ); 36 | 37 | $day = VisitsPerHour::select() 38 | ->whereField('date', $visitedAt->format('Y-m-d H:i:s')) 39 | ->first(); 40 | 41 | if (! $day) { 42 | $day = new VisitsPerHour( 43 | date: $visitedAt, 44 | count: 0, 45 | ); 46 | } 47 | 48 | $day->increment()->save(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Web/Analytics/chart.view.php: -------------------------------------------------------------------------------- 1 | <?php 2 | /** 3 | * @var \App\Web\Analytics\Chart $chart 4 | * @var string $label 5 | * @var string $title 6 | */ 7 | 8 | use Symfony\Component\Uid\Uuid; 9 | 10 | $uuid = Uuid::v4()->toString(); 11 | ?> 12 | 13 | <x-component name="x-chart"> 14 | <div class="grid gap-2"> 15 | <h2 class="font-bold">{{ $title }}</h2> 16 | <canvas id="<?= $uuid ?>"></canvas> 17 | 18 | <script> 19 | var ctx = document.getElementById('<?= $uuid ?>'); 20 | 21 | new Chart(ctx, { 22 | type: 'line', 23 | data: { 24 | labels: <?= json_encode($chart->labels->values()->toArray()) ?>, 25 | datasets: [{ 26 | label: '<?= $label ?>', 27 | data: <?= json_encode($chart->values->values()->toArray()) ?>, 28 | borderWidth: 2 29 | }] 30 | }, 31 | options: { 32 | elements: { 33 | line: { 34 | tension: 0.4 35 | } 36 | }, 37 | scales: { 38 | y: { 39 | beginAtZero: true 40 | } 41 | } 42 | } 43 | }); 44 | </script> 45 | </div> 46 | </x-component> 47 | -------------------------------------------------------------------------------- /src/Web/Analytics/stats.view.php: -------------------------------------------------------------------------------- 1 | <x-base> 2 | <x-slot name="head"> 3 | <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> 4 | </x-slot> 5 | 6 | <div class="@HeroBlock"> 7 | <div class="flex flex-col gap-4 px-4 items-center justify-center py-8 max-w-screen-xl mx-auto w-full text-white"> 8 | <div class=" flex-1 flex flex-col items-center justify-center gap-8"> 9 | <h1 class="text-[1.5rem] md:text-[2.5rem] max-w-[640px] leading-[1.1] text-center font-display font-extrabold">Tempest Stats</h1> 10 | </div> 11 | </div> 12 | </div> 13 | 14 | <div class="w-full flex flex-col "> 15 | <div class="w-full z-10 md:py-24 pt-12"> 16 | <div class="w-full mx-auto grid md:grid-cols-2 xl:grid-cols-3 gap-12 md:px-24 px-8"> 17 | <x-chart :chart="$visitsPerHour" label="Visits per hour" title="Website visits last 24 hours"></x-chart> 18 | <x-chart :chart="$visitsPerDay" label="Visits per day" title="Website visits last 30 days"></x-chart> 19 | </div> 20 | </div> 21 | </div> 22 | </x-base> 23 | -------------------------------------------------------------------------------- /src/Web/Blog/Author.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Blog; 4 | 5 | enum Author: string 6 | { 7 | case BRENT = 'brent'; 8 | 9 | public function getName(): string 10 | { 11 | return match ($this) { 12 | self::BRENT => 'Brent', 13 | }; 14 | } 15 | 16 | public function getBluesky(): string 17 | { 18 | return match ($this) { 19 | self::BRENT => 'brendt.bsky.social', 20 | }; 21 | } 22 | 23 | public function getX(): string 24 | { 25 | return match ($this) { 26 | self::BRENT => 'brendt_gd', 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Web/Blog/BlogController.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Blog; 4 | 5 | use App\Web\Meta\MetaType; 6 | use DateTimeImmutable; 7 | use Tempest\Cache\Cache; 8 | use Tempest\DateTime\DateTime; 9 | use Tempest\Http\Response; 10 | use Tempest\Http\Responses\NotFound; 11 | use Tempest\Http\Responses\Ok; 12 | use Tempest\Router\Get; 13 | use Tempest\Router\StaticPage; 14 | use Tempest\Support\Arr\ImmutableArray; 15 | use Tempest\View\View; 16 | 17 | use function Tempest\view; 18 | 19 | final readonly class BlogController 20 | { 21 | #[Get('/blog')] 22 | #[StaticPage] 23 | public function index(BlogRepository $repository): View 24 | { 25 | $posts = $repository->all(); 26 | 27 | return view('./index.view.php', posts: $posts, metaType: MetaType::BLOG); 28 | } 29 | 30 | #[Get('/blog/{slug}')] 31 | #[StaticPage(BlogDataProvider::class)] 32 | public function show(string $slug, BlogRepository $repository): Response|View 33 | { 34 | $post = $repository->find($slug); 35 | 36 | if (! $post || ! $post->published) { 37 | return new NotFound(); 38 | } 39 | 40 | return view('./show.view.php', post: $post); 41 | } 42 | 43 | #[Get('/rss')] 44 | public function rss( 45 | Cache $cache, 46 | BlogRepository $repository, 47 | ): Response { 48 | $xml = $cache->resolve( 49 | key: 'rss', 50 | callback: fn () => $this->renderRssFeed($repository->all(loadContent: true)), 51 | expiration: DateTime::now()->plusHours(1), 52 | ); 53 | 54 | return new Ok($xml) 55 | ->addHeader('Content-Type', 'application/xml;charset=UTF-8'); 56 | } 57 | 58 | private function renderRssFeed(ImmutableArray $posts): string 59 | { 60 | ob_start(); 61 | 62 | include __DIR__ . '/rss.view.php'; 63 | 64 | return trim(ob_get_clean()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Web/Blog/BlogDataProvider.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Blog; 4 | 5 | use Generator; 6 | use Tempest\Router\DataProvider; 7 | 8 | final readonly class BlogDataProvider implements DataProvider 9 | { 10 | public function __construct( 11 | private BlogRepository $repository, 12 | ) { 13 | } 14 | 15 | #[\Override] 16 | public function provide(): Generator 17 | { 18 | foreach ($this->repository->all() as $post) { 19 | yield ['slug' => $post->slug]; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Web/Blog/BlogIndexer.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Blog; 4 | 5 | use App\Web\CommandPalette\Command; 6 | use App\Web\CommandPalette\Type; 7 | use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; 8 | use League\CommonMark\MarkdownConverter; 9 | use Tempest\Support\Arr\ImmutableArray; 10 | 11 | use function Tempest\Support\arr; 12 | use function Tempest\Support\Arr\get_by_key; 13 | use function Tempest\Support\Arr\wrap; 14 | use function Tempest\uri; 15 | 16 | final readonly class BlogIndexer 17 | { 18 | public function __construct( 19 | private MarkdownConverter $markdown, 20 | ) { 21 | } 22 | 23 | public function __invoke(): ImmutableArray 24 | { 25 | return arr(glob(__DIR__ . '/articles/*.md')) 26 | ->map(function (string $path) { 27 | $markdown = $this->markdown->convert(file_get_contents($path)); 28 | preg_match('/\d+-\d+-\d+-(?<slug>.*)\.md/', $path, $matches); 29 | 30 | if (! ($markdown instanceof RenderedContentWithFrontMatter)) { 31 | throw new \RuntimeException(sprintf('Blog entry [%s] is missing a frontmatter.', $path)); 32 | } 33 | 34 | $frontmatter = $markdown->getFrontMatter(); 35 | $title = get_by_key($frontmatter, 'title'); 36 | $author = get_by_key($frontmatter, 'author'); 37 | $description = get_by_key($frontmatter, 'description'); 38 | $keywords = get_by_key($frontmatter, 'keywords'); 39 | $tags = get_by_key($frontmatter, 'tag'); 40 | 41 | $main = new Command( 42 | type: Type::URI, 43 | title: $title, 44 | uri: uri([BlogController::class, 'show'], slug: $matches['slug']), 45 | hierarchy: [ 46 | 'Blog', 47 | Author::tryFrom($author)?->getName() ?? 'Tempest', 48 | $title, 49 | ], 50 | fields: [ 51 | $author, 52 | $description, 53 | ...wrap($keywords), 54 | ...wrap($tags), 55 | ], 56 | ); 57 | 58 | return $main; 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Web/Blog/BlogPost.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Blog; 4 | 5 | use DateTimeImmutable; 6 | 7 | use function Tempest\uri; 8 | 9 | final class BlogPost 10 | { 11 | public string $slug; 12 | public string $title; 13 | public ?Author $author; 14 | public string $content; 15 | public DateTimeImmutable $createdAt; 16 | public ?string $tag = null; 17 | public ?string $description = null; 18 | public bool $published = true; 19 | public array $meta = []; 20 | public string $uri { 21 | get { 22 | return uri([BlogController::class, 'show'], slug: $this->slug); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Web/Blog/BlogRepository.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | namespace App\Web\Blog; 4 | 5 | use DateTimeImmutable; 6 | use Exception; 7 | use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; 8 | use League\CommonMark\MarkdownConverter; 9 | use Spatie\YamlFrontMatter\YamlFrontMatter; 10 | use Tempest\Support\Arr\ImmutableArray; 11 | 12 | use function Tempest\map; 13 | use function Tempest\Support\arr; 14 | 15 | final readonly class BlogRepository 16 | { 17 | public function __construct( 18 | private MarkdownConverter $markdown, 19 | ) { 20 | } 21 | 22 | /** 23 | * @return ImmutableArray<\App\Web\Blog\BlogPost> 24 | */ 25 | public function all(bool $loadContent = false): ImmutableArray 26 | { 27 | return arr(glob(__DIR__ . '/articles/*.md')) 28 | ->reverse() 29 | ->map(function (string $path) use ($loadContent) { 30 | preg_match('/\d+-\d+-\d+-(?<slug>.*)\.md/', $path, $matches); 31 | 32 | $data = [ 33 | 'slug' => $matches['slug'], 34 | 'createdAt' => $this->parseDate($path), 35 | 'tag' => null, 36 | 'description' => null, 37 | ...YamlFrontMatter::parse(file_get_contents($path))->matter(), 38 | ]; 39 | 40 | if ($loadContent) { 41 | $data['content'] = $this->parseContent($path)->getContent(); 42 | } 43 | 44 | return $data; 45 | }) 46 | ->mapTo(BlogPost::class) 47 | ->filter(fn (BlogPost $post) => $post->published); 48 | } 49 | 50 | public function find(string $slug): ?BlogPost 51 | { 52 | $path = glob(__DIR__ . "/articles/*{$slug}*.md")[0] ?? null; 53 | 54 | if (! $path) { 55 | return null; 56 | } 57 | 58 | $content = $this->parseContent($path); 59 | 60 | $data = [ 61 | 'slug' => $slug, 62 | 'content' => $content->getContent(), 63 | 'createdAt' => $this->parseDate($path), 64 | ...$content->getFrontMatter(), 65 | ]; 66 | 67 | return map($data)->to(BlogPost::class); 68 | } 69 | 70 | private function parseContent(string $path): ?RenderedContentWithFrontMatter 71 | { 72 | $content = @file_get_contents($path); 73 | 74 | if (! $content) { 75 | return null; 76 | } 77 | 78 | $parsed = $this->markdown->convert($content); 79 | 80 | if (! ($parsed instanceof RenderedContentWithFrontMatter)) { 81 | throw new Exception("Missing frontmatter or content in {$path}"); 82 | } 83 | 84 | return $parsed; 85 | } 86 | 87 | private function parseDate(string $path): DateTimeImmutable 88 | { 89 | preg_match('#\d+-\d+-\d+#', $path, $matches); 90 | 91 | $date = $matches[0] ?? null; 92 | 93 | return new DateTimeImmutable($date ?? 'now'); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Web/Blog/index.view.php: -------------------------------------------------------------------------------- 1 | <?php 2 | /** @var \App\Web\Blog\BlogPost[] $posts */ 3 | ?> 4 | 5 | <x-base title="Blog"> 6 | <!-- Main container --> 7 | <main class="container px-4 mx-auto xl:px-8 flex flex-col grow isolate"> 8 | <!-- Main content --> 9 | <div class="grow px-2 w-full lg:pl-12 flex flex-col min-w-0 lg:mt-10"> 10 | <!-- Header --> 11 | <div class="flex flex-col pb-8 max-w-xl"> 12 | <h1 class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl lg:text-5xl">Blog</h1> 13 | <p class="mt-4 text-lg text-gray-500 dark:text-gray-400"> 14 | Read the latest news and announcements about Tempest, from framework updates to real-world applications and expert insights. 15 | </p> 16 | <div class="mt-2"> 17 | <a href="/rss" class="rounded font-semibold inline-flex items-center focus:outline-hidden disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1 text-sm gap-1.5 ring ring-inset ring-(--ui-border-accented) text-(--ui-text) bg-(--ui-bg) hover:bg-(--ui-bg-elevated) disabled:bg-(--ui-bg) aria-disabled:bg-(--ui-bg) focus-visible:ring-2 focus-visible:ring-(--ui-border-inverted)" rel="noopener noreferrer" target="_blank"> 18 | <x-icon name="tabler:rss" class="shrink-0 size-4" /> 19 | RSS 20 | </a> 21 | </div> 22 | </div> 23 | <!-- Articles --> 24 | <ul class="grid md:grid-cols-2 lg:grid-cols-3 gap-4 lg:gap-8 mt-0 mb-8 lg:mt-4 2xl:mt-8"> 25 | <li :foreach="$posts as $post" class="flex flex-col justify-between relative border-dashed border border-(--ui-border-accented) hover:bg-(--ui-bg-elevated) rounded-lg p-4 transition"> 26 | <a class="absolute inset-0" :href="$post->uri"></a> 27 | <div> 28 | <span class="font-medium">{{ $post->title }}</span> 29 | <p class="text-[15px] text-(--ui-text-muted) mt-1 line-clamp-2">{{ $post->description }}</p> 30 | </div> 31 | <div class="flex items-center mt-6 gap-x-4 justify-between"> 32 | <span 33 | :if="$post->tag" 34 | :style="match ($post->tag) { 35 | 'Release' => '--badge: var(--ui-primary)', 36 | 'Thoughts' => '--badge: var(--ui-secondary)', 37 | 'Tutorial' => '--badge: var(--ui-info)', 38 | default => '--badge: var(--ui-secondary)', 39 | }" 40 | class="font-medium inline-flex items-center text-xs px-2 py-1 gap-1 rounded ring ring-inset ring-(--badge)/50 text-(--badge)" 41 | > 42 | {{ $post->tag }} 43 | </span> 44 | <span :if="$post->author" class="text-(--ui-text-muted) text-sm"> 45 | by <span class="font-medium">{{ $post->author->getName() }}</span> on <span class="font-medium">{{ $post->createdAt->format('F d, Y') }}</span> 46 | </span> 47 | </div> 48 | </li> 49 | </ul> 50 | </div> 51 | </main> 52 | </x-base> 53 | -------------------------------------------------------------------------------- /src/Web/Blog/rss.view.php: -------------------------------------------------------------------------------- 1 | <?php 2 | /** @var \App\Web\Blog\BlogPost[] $posts */ 3 | ?> 4 | 5 | <feed xmlns="http://www.w3.org/2005/Atom"> 6 | <id>https://tempestphp.com/rss</id> 7 | <link href="https://tempestphp.com/rss"/> 8 | <title><![CDATA[ tempestphp.com ]]> 9 | 10 | 11 | 12 | 13 | <![CDATA[ <?= $post->title ?> ]]> 14 | 15 | 16 | 17 | uri ?> 18 | 19 | 20 | 21 | 22 | 23 | content ?> ]]> 24 | 25 | createdAt->format('c') ?> 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Web/Blog/show.view.php: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 17 |
18 | 19 |
20 | 21 | 27 | 28 |
29 |

30 | {{ $post->title }} 31 |

32 |

33 | {{ $post->description }} 34 |

35 | 36 | by {{ $post->author->getName() }} on {{ $post->createdAt->format('F d, Y') }} 37 | 38 |
39 | 40 |
41 | {!! $post->content !!} 42 |
43 |
44 |
45 |
46 | -------------------------------------------------------------------------------- /src/Web/Code/CodeController.php: -------------------------------------------------------------------------------- 1 | get('lang') ?? 'php'; 24 | $code = $request->get('code') ?? ''; 25 | 26 | if ($code) { 27 | $code = urldecode(base64_decode($code, strict: true)); 28 | } 29 | 30 | return view(__DIR__ . '/code.view.php', code: $code, language: $language); 31 | } 32 | 33 | #[Post('/code/submit')] 34 | public function submit(Request $request): Redirect 35 | { 36 | $code = $request->get('code'); 37 | 38 | $language = $request->get('lang', 'php'); 39 | 40 | $code = urlencode(base64_encode($code)); 41 | 42 | return new Redirect(uri([self::class, 'preview']) . '?lang=' . $language . '&code=' . $code); 43 | } 44 | 45 | #[Get('/code/preview')] 46 | public function preview( 47 | Request $request, 48 | #[Tag('project')] 49 | Highlighter $highlighter, 50 | ): View { 51 | $code = $request->get('code') ?? urlencode(base64_encode('// Hello world')); 52 | 53 | $language = $request->get('lang') ?? 'php'; 54 | 55 | $editUrl = uri([self::class, 'paste'], lang: $language, code: $code); 56 | 57 | $highlightedCode = $highlighter->parse(urldecode(base64_decode($code, strict: true)), $language); 58 | 59 | return view(__DIR__ . '/code_preview.view.php')->data( 60 | code: $highlightedCode, 61 | editUrl: $editUrl, 62 | language: $language, 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Web/Code/EllisonController.php: -------------------------------------------------------------------------------- 1 | get('ellison')); 31 | 32 | return new Redirect(uri([self::class, 'preview']))->addSession('ellison', base64_encode($ellison)); 33 | } 34 | 35 | #[Get('/ellison/preview')] 36 | public function preview(Request $request): View 37 | { 38 | $highlighter = new Highlighter(new CssTheme()); 39 | 40 | $ellison = $highlighter->parse(base64_decode($request->getSessionValue('ellison') ?? base64_encode('Hello World'), strict: true), 'ellison'); 41 | 42 | return view(__DIR__ . '/ellison_preview.view.php')->data(ellison: $ellison); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Web/Code/code.view.php: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 18 | 19 | 20 |
21 | 22 |
23 | 24 | 25 | 26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /src/Web/Code/code_preview.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 24 | 25 |
26 |
27 |
28 |
29 |
30 | 31 |
32 | -------------------------------------------------------------------------------- /src/Web/Code/ellison.view.php: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 |
11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /src/Web/Code/ellison_preview.view.php: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | Ellison | Tempest 7 | 8 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |
raw('ellison') ?>
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/.gitignore: -------------------------------------------------------------------------------- 1 | index.json 2 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/Command.php: -------------------------------------------------------------------------------- 1 | $this->type->value, 24 | 'title' => $this->title, 25 | 'uri' => $this->uri, 26 | 'javascript' => $this->javascript, 27 | 'hierarchy' => $this->hierarchy, 28 | 'fields' => $this->fields, 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/CommandIndexer.php: -------------------------------------------------------------------------------- 1 | console->task( 26 | label: 'Exporting search index', 27 | handler: fn () => file_put_contents( 28 | __DIR__ . '/index.json', 29 | json_encode([ 30 | ...($this->documentationIndexer)(Version::default()), 31 | ...($this->blogIndexer)(), 32 | ...($this->commandIndexer)(), 33 | ]), 34 | ), 35 | ); 36 | 37 | return ExitCode::SUCCESS; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/Type.php: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | DialogContent, 4 | DialogDescription, 5 | DialogOverlay, 6 | DialogRoot, 7 | DialogTitle, 8 | } from 'reka-ui' 9 | 10 | defineProps<{ 11 | contentClass?: string 12 | title: string 13 | }>() 14 | 15 | const open = defineModel('open', { required: true }) 16 | 17 | 18 | 32 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/command-palette.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 92 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/palette.entrypoint.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import search from './command-palette.vue' 3 | 4 | // Create the palette instance 5 | createApp(search).mount('#command-palette') 6 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/register-palette.ts: -------------------------------------------------------------------------------- 1 | import { useMagicKeys, whenever } from '@vueuse/core' 2 | import type { Ref } from 'vue' 3 | 4 | interface Options { 5 | value: Ref 6 | } 7 | 8 | /** 9 | * Registers `/` and `Cmd+K` hotkeys, as well as a `toggleCommandPalette` function. 10 | */ 11 | export function registerPalette(options: Options) { 12 | const { Meta_K, Slash } = useMagicKeys({ 13 | target: document.body, 14 | passive: false, 15 | onEventFired(e) { 16 | if (e.key === '/' && e.type === 'keydown') { 17 | e.preventDefault() 18 | } 19 | if (e.key === 'k' && e.type === 'keydown' && e.metaKey) { 20 | e.preventDefault() 21 | } 22 | }, 23 | }) 24 | 25 | function toggleCommandPalette() { 26 | options.value.value = !options.value.value 27 | } 28 | 29 | // @ts-expect-error window is not typed 30 | window.toggleCommandPalette = toggleCommandPalette 31 | 32 | window.document.querySelectorAll('[toggle-palette]').forEach((element) => { 33 | element.addEventListener('click', toggleCommandPalette) 34 | }) 35 | 36 | whenever(Meta_K, () => options.value.value = !options.value.value) 37 | whenever(Slash, () => options.value.value = true) 38 | } 39 | -------------------------------------------------------------------------------- /src/Web/CommandPalette/use-search.ts: -------------------------------------------------------------------------------- 1 | import type { FuseResult } from 'fuse.js' 2 | import Fuse from 'fuse.js' 3 | import type { Ref } from 'vue' 4 | import { shallowRef, watch } from 'vue' 5 | import { useTimeoutFn } from '@vueuse/core' 6 | import index from './index.json' 7 | 8 | interface Options { 9 | query: Ref 10 | open: Ref 11 | } 12 | 13 | interface Command { 14 | title: string 15 | hierarchy: string[] 16 | // eslint-disable-next-line ts/ban-types 17 | type: 'uri' | 'js' | (string & {}) 18 | uri?: string | null 19 | javascript?: string | null 20 | } 21 | 22 | type SearchResultItem = FuseResult 23 | type TreeNode = Command & { 24 | children?: TreeNode[] 25 | } 26 | 27 | export function handleCommand(item: TreeNode, event: Event) { 28 | // @ts-expect-error not typed 29 | window.toggleCommandPalette?.() 30 | 31 | if (item.type === 'uri') { 32 | return event.preventDefault() 33 | } 34 | 35 | if (item.type === 'js') { 36 | window[item.javascript!]?.() 37 | } 38 | } 39 | 40 | function buildHierarchyTree(items: SearchResultItem[]): Record { 41 | const tree: Record = {} 42 | const seenTitles = new Set() 43 | 44 | for (const { item } of items) { 45 | const topLevel = item.hierarchy[0] 46 | 47 | if (!tree[topLevel]) { 48 | tree[topLevel] = { 49 | title: topLevel, 50 | children: [], 51 | hierarchy: [topLevel], 52 | type: item.type, 53 | uri: item.uri, 54 | javascript: item.javascript, 55 | } 56 | } 57 | 58 | if (!seenTitles.has(item.hierarchy.join('_'))) { 59 | tree[topLevel].children!.push({ 60 | hierarchy: item.hierarchy, 61 | title: item.title, 62 | uri: item.uri, 63 | javascript: item.javascript, 64 | type: item.type, 65 | }) 66 | seenTitles.add(item.hierarchy.join('_')) 67 | } 68 | } 69 | 70 | return tree 71 | } 72 | 73 | export function useSearch(options: Options) { 74 | const results = shallowRef>({}) 75 | const fuse = new Fuse(index, { 76 | keys: ['title', 'hierarchy', 'fields'], 77 | includeScore: true, 78 | shouldSort: true, 79 | threshold: 0.25, 80 | }) 81 | 82 | // Filters the palette commands based on the query, specifying default commands if empty 83 | watch(options.query, (query) => { 84 | if (!query.length) { 85 | results.value = { Commands: { title: 'Commands', type: 'uri', hierarchy: ['Commands'], children: index.filter((item) => item.hierarchy.at(0) === 'Commands') } } 86 | } else { 87 | results.value = buildHierarchyTree(fuse.search(query) as SearchResultItem[]) 88 | } 89 | }, { immediate: true }) 90 | 91 | // Resets the command palette after 3s of being closed 92 | const reset = useTimeoutFn(() => options.query.value = '', 3_000) 93 | watch(options.open, (isOpen) => { 94 | reset.stop() 95 | 96 | if (isOpen) { 97 | return 98 | } 99 | 100 | reset.start() 101 | }) 102 | 103 | return { 104 | results, 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Web/Documentation/Chapter.php: -------------------------------------------------------------------------------- 1 | version, category: $this->category, slug: $this->slug); 28 | } 29 | 30 | public function getMetaUri(): string 31 | { 32 | return uri([MetaImageController::class, 'documentation'], version: $this->version, category: $this->category, slug: $this->slug); 33 | } 34 | 35 | public function getEditPageUri(): string 36 | { 37 | return "https://github.com/tempestphp/tempest-docs/edit/main/{$this->path}"; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Web/Documentation/ChapterController.php: -------------------------------------------------------------------------------- 1 | value}/*", flags: GLOB_ONLYDIR)) 35 | ->sort() 36 | ->mapFirstTo(ImmutableString::class) 37 | ->basename() 38 | ->toString(); 39 | 40 | $slug = arr(glob(__DIR__ . "/content/{$version->value}/{$category}/*.md")) 41 | ->map(fn (string $path) => before_first(basename($path), '.')) 42 | ->sort() 43 | ->mapFirstTo(ImmutableString::class) 44 | ->basename() 45 | ->toString(); 46 | 47 | return new Redirect(uri( 48 | [self::class, '__invoke'], 49 | version: $version, 50 | category: $category, 51 | slug: $slug, 52 | )); 53 | } 54 | 55 | #[StaticPage(DocumentationDataProvider::class)] 56 | #[Get('/{version}/{category}/{slug}')] 57 | public function __invoke(string $version, string $category, string $slug, ChapterRepository $chapterRepository): View|Response 58 | { 59 | if (is_null($version = Version::tryFromString($version))) { 60 | return new NotFound(); 61 | } 62 | 63 | $currentChapter = $chapterRepository->find($version, $category, $slug); 64 | 65 | if (! $currentChapter || $currentChapter->hidden) { 66 | return new NotFound(); 67 | } 68 | 69 | return new ChapterView( 70 | version: $version, 71 | chapterRepository: $chapterRepository, 72 | currentChapter: $currentChapter, 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Web/Documentation/ChapterRepository.php: -------------------------------------------------------------------------------- 1 | value}", 32 | pattern: sprintf("#.*\/(\d+-)?%s\/(\d+-)?%s\.md#", preg_quote($category, '#'), preg_quote($slug, '#')), 33 | ))->sort()->first(); 34 | 35 | if (! $path) { 36 | return null; 37 | } 38 | 39 | $markdown = $this->markdown->convert(file_get_contents($path)); 40 | 41 | if (! ($markdown instanceof RenderedContentWithFrontMatter)) { 42 | throw new \RuntimeException(sprintf('Documentation entry [%s] is missing a frontmatter.', $path)); 43 | } 44 | 45 | $frontmatter = $markdown->getFrontMatter(); 46 | $title = get_by_key($frontmatter, 'title'); 47 | $description = get_by_key($frontmatter, 'description'); 48 | $hidden = get_by_key($frontmatter, 'hidden'); 49 | 50 | return new Chapter( 51 | version: $version, 52 | category: $category, 53 | slug: $slug, 54 | body: $markdown->getContent(), 55 | title: $title, 56 | path: to_relative_path(root_path(), $path), 57 | hidden: $hidden ?? false, 58 | description: $description ?? null, 59 | ); 60 | } 61 | 62 | /** 63 | * @return ImmutableArray 64 | */ 65 | public function all(Version $version, string $category = '*'): ImmutableArray 66 | { 67 | return arr(glob(__DIR__ . "/content/{$version->value}/*{$category}/*.md")) 68 | ->map(function (string $path) use ($version) { 69 | $content = file_get_contents($path); 70 | $category = str($path)->beforeLast('/')->afterLast('/')->replaceRegex('/^\d+-/', ''); 71 | 72 | preg_match('/(?\d+-)?(?.*)\.md/', pathinfo($path, PATHINFO_BASENAME), $matches); 73 | 74 | return [ 75 | 'version' => $version, 76 | 'slug' => replace($matches['slug'], '/^\d+-/', ''), 77 | 'index' => $matches['index'], 78 | 'category' => $category->toString(), 79 | 'path' => to_relative_path(root_path(), $path), 80 | ...YamlFrontMatter::parse($content)->matter(), 81 | ]; 82 | }) 83 | ->filter(fn (array $chapter) => get_by_key($chapter, 'hidden') !== true) 84 | ->mapTo(Chapter::class); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Web/Documentation/DocumentationDataProvider.php: -------------------------------------------------------------------------------- 1 | chapterRepository->all($version) as $chapter) { 21 | yield [ 22 | 'version' => $chapter->version->value, 23 | 'category' => $chapter->category, 24 | 'slug' => $chapter->slug, 25 | ]; 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Web/Documentation/RedirectMiddleware.php: -------------------------------------------------------------------------------- 1 | path); 31 | $response = $next($request); 32 | $matched = get(MatchedRoute::class); 33 | 34 | // If not a docs page, let's just continue normal flow 35 | if ($matched->route->uri !== '/{version}/{category}/{slug}') { 36 | return $response; 37 | } 38 | 39 | // Redirect to slugs without numbers 40 | if (matches($matched->params['category'], '/^\d+-/') || matches($matched->params['slug'], '/^\d+-/')) { 41 | return new Redirect($path->replaceRegex('/\/\d+-/', '/')); 42 | } 43 | 44 | // Redirect to actual version 45 | $version = Version::tryFromString(get_by_key($matched->params, 'version')); 46 | if ($version->value !== $matched->params['version']) { 47 | return new Redirect($path->replace("/{$matched->params['version']}/", "/{$version->value}/")); 48 | } 49 | 50 | return $response; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Web/Documentation/Version.php: -------------------------------------------------------------------------------- 1 | self::default(), 24 | default => self::tryFrom($case), 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Web/Documentation/content/main/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/src/Web/Documentation/content/main/.gitkeep -------------------------------------------------------------------------------- /src/Web/Documentation/content/main/2-features/04-authentication.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Authentication and authorization 3 | keywords: "Experimental" 4 | --- 5 | 6 | :::warning 7 | The authentication and authorization implementations of Tempest are currently experimental. Although you can use them, please note that they are not covered by our backwards compatibility promise. 8 | ::: 9 | 10 | ## Overview 11 | 12 | Logging in (authentication )and verifying whether a user is allowed to perform a specific action (authorization) are two crucial parts of any web application. Tempest comes with a built-in authenticator and authorizer, as well as a base `User` and `Permission` model (if you want to). 13 | 14 | ## Authentication 15 | 16 | Logging in a user can be done with the `Authenticator` class: 17 | 18 | ```php 19 | // app/AuthController.php 20 | 21 | use Tempest\Auth\Authenticator; 22 | use Tempest\Router\Request; 23 | use Tempest\Router\Response; 24 | use Tempest\Router\Responses\Redirect; 25 | 26 | final readonly class AuthController 27 | { 28 | public function __construct( 29 | private Authenticator $authenticator 30 | ) {} 31 | 32 | #[Post('/login')] 33 | public function login(Request $request): Response 34 | { 35 | $user = // … 36 | 37 | $this->authenticator->login($user); 38 | 39 | return new Redirect('/'); 40 | } 41 | } 42 | ``` 43 | 44 | Note that Tempest currently doesn't provide user management support (resolving a user from a request, user registration, password reset flow, etc.). 45 | 46 | ## Authorization 47 | 48 | You can protect controller routes using the `#[Allow]` attribute: 49 | 50 | ```php 51 | // app/AdminController.php 52 | 53 | use Tempest\Auth\Allow; 54 | use Tempest\Router\Response; 55 | 56 | final readonly class AdminController 57 | { 58 | #[Allow('permission')] 59 | public function index(): Response 60 | { 61 | // … 62 | } 63 | } 64 | ``` 65 | 66 | Tempest uses a permission-based authorizer. That means that, in order for users to be allowed access to a route, they'll need to be granted the right permission. Permissions can be represented as strings or enums: 67 | 68 | ```php 69 | // app/AdminController.php 70 | 71 | use Tempest\Auth\Allow; 72 | use Tempest\Router\Response; 73 | 74 | final readonly class AdminController 75 | { 76 | #[Allow(UserPermission::ADMIN)] 77 | public function index(): Response 78 | { 79 | // … 80 | } 81 | } 82 | ``` 83 | 84 | ## Built-in user model 85 | 86 | Tempest's authenticator and authorizer are compatible with any class implementing the {`Tempest\Auth\CanAuthenticate`} and {`Tempest\Auth\CanAuthorize`} interfaces. However, Tempest comes with a pre-built `User` model that makes it easier to get started. In order to use Tempest's `User` implementation, you must install the auth files: 87 | 88 | ``` 89 | ./tempest install auth 90 | ./tempest migrate:up 91 | ``` 92 | 93 | With this `User` model, you already have a lot of helper methods in place to build your own user management flow: 94 | 95 | ```php 96 | use App\Auth\User; 97 | 98 | $user = new User( 99 | name: 'Brent', 100 | email: 'brendt@stitcher.io', 101 | ) 102 | ->setPassword('password') 103 | ->save() 104 | ->grantPermission('admin'); 105 | ``` 106 | -------------------------------------------------------------------------------- /src/Web/Documentation/content/main/2-features/07-mail.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Mail 3 | description: "" 4 | hidden: true 5 | --- 6 | -------------------------------------------------------------------------------- /src/Web/Documentation/content/main/2-features/11-scheduling.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Scheduling 3 | description: 'Tempest provides a modern and convenient way of scheduling tasks, which can be any class method, even existing console commands.' 4 | --- 5 | 6 | ## Overview 7 | 8 | Dealing with repeating, scheduled tasks is as simple as adding the {`#[Tempest\Console\Schedule]`} attribute to any class method. As with console commands, [discovery](../4-internals/02-discovery.md) takes care of finding these methods and registering them. 9 | 10 | ## Using the scheduler 11 | 12 | To run tasks on your server, a single cron task is required. This task should call the `schedule:run` command, which will evaluate which scheduled task should be run at the current time. 13 | 14 | ``` 15 | 0 * * * * user /path/to/{*tempest schedule:run*} 16 | ``` 17 | 18 | ## Defining schedules 19 | 20 | Any method using the `{php}#[Schedule]` attribute will be run by the scheduler. As with everything Tempest, these methods are discovered automatically. 21 | 22 | ```php src/ScheduledTasks.php 23 | use Tempest\Console\Schedule; 24 | use Tempest\Console\Scheduler\Every; 25 | 26 | final readonly class ScheduledTasks 27 | { 28 | #[Schedule(Every::HOUR)] 29 | public function updateSlackChannels(): void 30 | { 31 | // … 32 | } 33 | } 34 | ``` 35 | 36 | For most common scheduling use-cases, the {b`Tempest\Console\Scheduler\Every`} enumeration can be used. In case you need more fine-grained control, you can pass in an {b`Tempest\Console\Scheduler\Interval`} object instead: 37 | 38 | ```php 39 | use Tempest\Console\Schedule; 40 | use Tempest\Console\Scheduler\Interval; 41 | 42 | #[Schedule(new Interval(hours: 2, minutes: 30))] 43 | public function updateSlackChannels(): void 44 | { 45 | // … 46 | } 47 | ``` 48 | 49 | Note that scheduled task don't have to be console commands, but they can be both. This is handy when you need a task to be run on a schedule, but also want to be able to run it manually. 50 | 51 | ```php 52 | use Tempest\Console\ConsoleCommand; 53 | use Tempest\Console\Schedule; 54 | 55 | #[Schedule(Every::HOUR)] 56 | #[ConsoleCommand('slack:update-channels')] 57 | public function updateSlackChannels(): void 58 | { 59 | // … 60 | } 61 | ``` 62 | -------------------------------------------------------------------------------- /src/Web/Documentation/content/main/2-features/12-http-client.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: HTTP client 3 | description: "" 4 | hidden: true 5 | --- 6 | -------------------------------------------------------------------------------- /src/Web/Documentation/content/main/3-packages/02-console.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Console 3 | description: "The console component can be used as a standalone package to build console applications." 4 | --- 5 | 6 | ## Installation and usage 7 | 8 | Tempest's console component can be used standalone. You simply need to require the `tempest/console` package: 9 | 10 | ```sh 11 | composer require tempest/console:1.0-beta.1 12 | ``` 13 | 14 | Once installed, you may boot a console application as follows. 15 | 16 | ```php ./my-cli 17 | {:hl-comment:#!/usr/bin/env php:} 18 | run(); 25 | ``` 26 | 27 | ## Registering commands 28 | 29 | `tempest/console` relies on [discovery](../4-internals/02-discovery.md) to find and register console commands. That means you don't have to register any commands manually, and any method within your codebase using the `{php}#[ConsoleCommand]` attribute will automatically be discovered by your console application. 30 | 31 | You may read more about building commands in the [dedicated documentation](../1-essentials/04-console-commands.md). 32 | 33 | ## Configuring discovery 34 | 35 | Tempest will discover all console commands within namespaces configured as valid PSR-4 namespaces, as well as all third-party packages that require Tempest. 36 | 37 | ```json 38 | { 39 | "autoload": { 40 | "psr-4": { 41 | "App\\": "app/" 42 | } 43 | } 44 | } 45 | ``` 46 | 47 | In case you need more fine-grained control over which directories to discover, you may provide a custom {`Tempest\Core\AppConfig`} instance to the `{php}ConsoleApplication::boot()` method: 48 | 49 | ```php 50 | use Tempest\AppConfig; 51 | use Tempest\Core\DiscoveryLocation; 52 | use Tempest\Console\ConsoleApplication; 53 | 54 | $appConfig = new AppConfig( 55 | discoveryLocations: [ 56 | new DiscoveryLocation( 57 | namespace: 'App\\', 58 | path: __DIR__ . '/app/', 59 | ), 60 | ], 61 | ); 62 | 63 | ConsoleApplication::boot(appConfig: $appConfig)->run(); 64 | ``` 65 | -------------------------------------------------------------------------------- /src/Web/Documentation/content/main/4-internals/01-bootstrap.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Framework bootstrap 3 | description: "Learn the steps involved in bootstrapping the framework." 4 | --- 5 | 6 | ## Overview 7 | 8 | Here's a short summary of what booting Tempest looks like. 9 | 10 | - The entry point is either `public/index.html` or `./tempest`. 11 | - Tempest boots using the {b`\Tempest\Core\FrameworkKernel`}. 12 | - Bootstrap classes are located in the [`Tempest\Core\Kernel`](https://github.com/tempestphp/tempest-framework/tree/main/packages/core/src/Kernel) namespace. 13 | - First, discovery is started through the {b`\Tempest\Core\LoadDiscoveryLocations`} and {b`\Tempest\Core\LoadDiscoveryClasses`} classes. 14 | - Then, configuration files are registered through the {b`\Tempest\Core\LoadConfig`} class. 15 | - When bootstrapping is completed, the `Tempest\Core\KernelEvent::BOOTED` event is fired. 16 | -------------------------------------------------------------------------------- /src/Web/Homepage/HomeController.php: -------------------------------------------------------------------------------- 1 | $this->markdown->convert(file_get_contents($path)); 28 | }); 29 | 30 | return view('./home.view.php', codeBlocks: $codeBlocks); 31 | } 32 | 33 | #[Get('/view')] 34 | public function viewRedirect(): Redirect 35 | { 36 | return new Redirect('/main/framework/views'); 37 | } 38 | 39 | #[Get('/console')] 40 | public function consoleRedirect(): Redirect 41 | { 42 | return new Redirect('/main/console/getting-started'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/config.md: -------------------------------------------------------------------------------- 1 | ```php 2 | return new SQLiteConfig( 3 | path: env('DB_PATH', __DIR__ . '/../database.sqlite'), 4 | ); 5 | ``` 6 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/console.md: -------------------------------------------------------------------------------- 1 | ```php 2 | final readonly class BooksCommand 3 | { 4 | use HasConsole; 5 | 6 | public function __construct( 7 | private BookRepository $repository, 8 | ) {} 9 | 10 | #[ConsoleCommand] 11 | public function find(): void 12 | { 13 | $book = $this->search( 14 | 'Find your book', 15 | $this->repository->find(...), 16 | ); 17 | } 18 | 19 | #[ConsoleCommand(middleware: [CautionMiddleware::class])] 20 | public function delete(string $title, bool $verbose = false): void 21 | { /* … */ } 22 | } 23 | ``` -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/controller.md: -------------------------------------------------------------------------------- 1 | ```php 2 | final readonly class BookController 3 | { 4 | #[Post('/books')] 5 | public function store(CreateBookRequest $request): Response 6 | { 7 | $book = map($request)->to(Book::class)->save(); 8 | 9 | return new Redirect(uri([self::class, 'show'], book: $book->id)); 10 | } 11 | } 12 | ``` 13 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/event-handler.md: -------------------------------------------------------------------------------- 1 | ```php 2 | final readonly class BookObserver 3 | { 4 | #[EventHandler] 5 | public function onBookPublished(BookPublished $event): void 6 | { 7 | // … 8 | } 9 | } 10 | ``` -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/mapper.md: -------------------------------------------------------------------------------- 1 | ```php 2 | use function Tempest\map; 3 | 4 | map('path/to/books.json')->collection->to(Book::class); 5 | 6 | map($book)->to(MapTo::JSON); 7 | ``` 8 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/markdown-initializer.md: -------------------------------------------------------------------------------- 1 | ```php 2 | final readonly class MarkdownInitializer implements Initializer 3 | { 4 | public function initialize(Container $container): MarkdownConverter 5 | { 6 | $highlighter = new Highlighter(new CssTheme()) 7 | ->addLanguage(new TempestViewLanguage()); 8 | 9 | $environment = new Environment() 10 | ->addRenderer(Code::class, new InlineCodeBlockRenderer($highlighter)); 11 | 12 | return new MarkdownConverter($environment); 13 | } 14 | } 15 | ``` 16 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/model.md: -------------------------------------------------------------------------------- 1 | ```php 2 | final class Book 3 | { 4 | #[Length(min: 1, max: 120)] 5 | public string $title; 6 | 7 | public ?Author $author = null; 8 | 9 | /** @var \App\Books\Chapter[] */ 10 | public array $chapters = []; 11 | } 12 | ``` 13 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/orm.md: -------------------------------------------------------------------------------- 1 | ```php 2 | $book = query(Book::class) 3 | ->select() 4 | ->where('title', 'Timeline Taxi') 5 | ->first(); 6 | 7 | // … 8 | 9 | $json = map($book)->toJson(); 10 | ``` 11 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/query.md: -------------------------------------------------------------------------------- 1 | ```php 2 | query('authors') 3 | ->insert(...$rows) 4 | ->execute(); 5 | ``` -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/static-pages.md: -------------------------------------------------------------------------------- 1 | ```console 2 | ./tempest static:generate 3 | 4 | /framework/01-getting-started .... /public/framework/01-getting-started/index.html 5 | /framework/02-the-container ........ /public/framework/02-the-container/index.html 6 | /framework/03-controllers ............ /public/framework/03-controllers/index.html 7 | /framework/04-views ........................ /public/framework/04-views/index.html 8 | /framework/05-models ...................... /public/framework/05-models/index.html 9 | 10 | ``` 11 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/templating-component.md: -------------------------------------------------------------------------------- 1 | ```html 2 | 3 | 4 | 5 | {{ $title }} — Books 6 | Books 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ``` 18 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/templating-view.md: -------------------------------------------------------------------------------- 1 | ```html 2 | 3 |
    4 |
  • 5 | {{ $book->title }} 6 | 7 | 8 | 9 | {{ $book->publishedAt }} 10 | 11 | 12 |
  • 13 |
14 |
15 | ``` 16 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/view-component.md: -------------------------------------------------------------------------------- 1 | ```html 2 | 3 | 4 |
5 |

{{ $book->title }}

6 | 7 | {!! $book->body !!} 8 |
9 | ``` 10 | -------------------------------------------------------------------------------- /src/Web/Homepage/codeblocks/view-processor.md: -------------------------------------------------------------------------------- 1 | ```php 2 | final class StarCountViewProcessor implements ViewProcessor 3 | { 4 | public function __construct( 5 | private readonly GitHub $github, 6 | ) {} 7 | 8 | public function process(View $view): View 9 | { 10 | if (! $view instanceof WithStarCount) { 11 | return $view; 12 | } 13 | 14 | return $view->data(starCount: $this->github->getStarCount()); 15 | } 16 | } 17 | ``` 18 | -------------------------------------------------------------------------------- /src/Web/Homepage/x-aurora.view.php: -------------------------------------------------------------------------------- 1 |
2 |
29 |
30 | -------------------------------------------------------------------------------- /src/Web/Homepage/x-falling-leaves.view.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 11 | 19 | 26 | -------------------------------------------------------------------------------- /src/Web/Homepage/x-home-section.view.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | 7 | {{ $heading }} 8 | 9 |

10 | {{ $paragraph }} 11 |

12 |
13 | 19 |
20 | 21 |
22 |
23 | {!! $codeBlocks[$snippet] !!} 24 |
25 |
26 |
27 |
28 | 30 | -------------------------------------------------------------------------------- /src/Web/Homepage/x-moonlight.view.php: -------------------------------------------------------------------------------- 1 | 2 | 28 | -------------------------------------------------------------------------------- /src/Web/Homepage/x-rain.view.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | -------------------------------------------------------------------------------- /src/Web/LatestReleaseViewProcessor.php: -------------------------------------------------------------------------------- 1 | data(latest_release: ($this->getLatestRelease)()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Web/Meta/MetaType.php: -------------------------------------------------------------------------------- 1 | value); 15 | } 16 | 17 | public function getViewPath(): string 18 | { 19 | return match ($this) { 20 | self::BLOG => __DIR__ . '/views/blog-index.view.php', 21 | default => __DIR__ . '/views/default.view.php', 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Web/Meta/views/blog-index.view.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | Blog 4 |
5 | 6 | Read the latest news and announcements about Tempest, from framework updates to real-world applications and expert insights. 7 | 8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /src/Web/Meta/views/blog.view.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 |
7 | {{ $post->title }} 8 |
9 | by 10 | {{ $post->author->getName() }} 11 | on 12 | {{ $post->createdAt->format('F d, Y') }} 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /src/Web/Meta/views/default.view.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | {{ $title ?? 'Tempest' }} 4 |
5 | {{ $subtitle ?? 'The PHP framework that gets out of your way.' }} 6 |
7 |
8 |
9 | -------------------------------------------------------------------------------- /src/Web/Meta/views/documentation.view.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 |
7 | {{ $chapter->title }} 8 |
9 | Documentation 10 | / 11 | {{ \Tempest\Support\Str\to_sentence_case($chapter->category) }} 12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /src/Web/Meta/x-meta-image.view.php: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | Meta 10 | 11 | 16 | 17 | 18 |
19 |
20 |
43 |
44 |
45 | 46 |
47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /src/Web/RedirectsController.php: -------------------------------------------------------------------------------- 1 | data(stargazers_count: ($this->getStargazersCount)()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Web/assets/copy-code-blocks.ts: -------------------------------------------------------------------------------- 1 | function extractPlainText(pre: HTMLElement, button?: HTMLButtonElement): string { 2 | return Array.from(pre.childNodes) 3 | .filter((node) => node !== button) 4 | .map((node) => 5 | node.nodeType === Node.TEXT_NODE 6 | ? node.textContent || '' 7 | : node.nodeType === Node.ELEMENT_NODE 8 | ? (node as HTMLElement).textContent || '' 9 | : '', 10 | ) 11 | .join('') 12 | .trim() 13 | } 14 | 15 | document.addEventListener('DOMContentLoaded', () => { 16 | const template = document.getElementById('copy-template') as HTMLTemplateElement | null 17 | if (!template) { 18 | return 19 | } 20 | 21 | document.querySelectorAll('.prose pre').forEach((pre) => { 22 | const copyButton = (template.content.cloneNode(true) as DocumentFragment).querySelector('button') as HTMLButtonElement | null 23 | if (!copyButton) { 24 | return 25 | } 26 | 27 | pre.classList.add('relative', 'group') 28 | pre.appendChild(copyButton) 29 | 30 | copyButton.addEventListener('click', () => { 31 | const content = extractPlainText(pre, copyButton) 32 | navigator.clipboard 33 | .writeText(content) 34 | .then(() => { 35 | copyButton.setAttribute('data-copied', 'true') 36 | setTimeout(() => copyButton.removeAttribute('data-copied'), 2000) 37 | }) 38 | .catch((err) => console.error('Copy failed', err)) 39 | }) 40 | }) 41 | }) 42 | 43 | document.addEventListener('DOMContentLoaded', () => { 44 | document.querySelectorAll('button[data-copy]').forEach((button) => { 45 | button.addEventListener('click', () => { 46 | const target = document.querySelector(button.dataset.copy!) as HTMLElement 47 | console.log(target) 48 | 49 | if (!target) { 50 | return 51 | } 52 | 53 | const content = extractPlainText(target) 54 | 55 | navigator.clipboard 56 | .writeText(content) 57 | .then(() => { 58 | button.setAttribute('data-copied', 'true') 59 | setTimeout(() => button.removeAttribute('data-copied'), 2000) 60 | }) 61 | .catch((err) => console.error('Copy failed', err)) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/Web/assets/highlight-current-prose-title.ts: -------------------------------------------------------------------------------- 1 | function findPreviousH2(element: Element | null): HTMLHeadingElement | null { 2 | while (element && element.previousElementSibling) { 3 | element = element.previousElementSibling 4 | if (element.tagName === 'H2' || element.tagName === 'H3') { 5 | return element as HTMLHeadingElement 6 | } 7 | } 8 | 9 | return null 10 | } 11 | 12 | function updateActiveChapters(): void { 13 | const visibleH2s = new Set() 14 | const elements = document.querySelectorAll('.prose *') 15 | const topMargin = 100 16 | 17 | for (const el of elements) { 18 | const rect = el.getBoundingClientRect() 19 | if (rect.top - topMargin >= 0 && rect.bottom <= window.innerHeight) { 20 | if (el.tagName === 'H3' || el.tagName === 'H2' || el.tagName === 'H1') { 21 | if (el.textContent) { 22 | visibleH2s.add(el.textContent.trim()) 23 | } 24 | } else { 25 | const previousH2 = findPreviousH2(el) 26 | if (previousH2 && previousH2.textContent) { 27 | visibleH2s.add(previousH2.textContent.trim()) 28 | } 29 | } 30 | } 31 | } 32 | 33 | document.querySelectorAll('[data-on-this-page]').forEach((link) => { 34 | const section = link.getAttribute('data-on-this-page') 35 | if (section && visibleH2s.has(section)) { 36 | link.setAttribute('data-active', 'true') 37 | } else { 38 | link.removeAttribute('data-active') 39 | } 40 | }) 41 | } 42 | 43 | document.addEventListener('DOMContentLoaded', () => { 44 | if (!document.querySelector('.prose[highlights-titles]')) { 45 | return 46 | } 47 | 48 | window.addEventListener('scroll', () => requestAnimationFrame(updateActiveChapters)) 49 | updateActiveChapters() 50 | }) 51 | -------------------------------------------------------------------------------- /src/Web/assets/main.entrypoint.ts: -------------------------------------------------------------------------------- 1 | import './register-fonts.ts' 2 | import './copy-code-blocks.ts' 3 | import './highlight-current-prose-title.ts' 4 | import './save-scroll.ts' 5 | -------------------------------------------------------------------------------- /src/Web/assets/register-fonts.ts: -------------------------------------------------------------------------------- 1 | import '@fontsource-variable/kantumruy-pro' 2 | import '@fontsource-variable/public-sans' 3 | -------------------------------------------------------------------------------- /src/Web/assets/save-scroll.ts: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | const elements = document.querySelectorAll('[data-save-scroll]') 3 | 4 | elements.forEach((element) => { 5 | const key = `scroll-pos-${element.getAttribute('data-save-scroll')}` 6 | 7 | const savedScroll = localStorage.getItem(key) 8 | if (savedScroll !== null) { 9 | element.scrollTop = Number.parseInt(savedScroll, 10) 10 | } 11 | 12 | element.addEventListener('scroll', () => { 13 | localStorage.setItem(key, element.scrollTop.toString()) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/Web/x-footer.view.php: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /src/Web/x-header.view.php: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 58 | 68 | -------------------------------------------------------------------------------- /src/Web/x-search.view.php: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /src/analytics.config.php: -------------------------------------------------------------------------------- 1 | getPathName(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/stored-events.config.php: -------------------------------------------------------------------------------- 1 | run(); 9 | 10 | exit; -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tempestphp/tempest-docs/0d192390df24061378554eb01ec48f2e70704008/tests/.gitkeep -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import tempest from 'vite-plugin-tempest' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | tailwindcss(), 9 | tempest(), 10 | vue(), 11 | ], 12 | }) 13 | --------------------------------------------------------------------------------