30 | {{ $post->title }} 31 |
32 |33 | {{ $post->description }} 34 |
35 | 36 | by {{ $post->author->getName() }} on {{ $post->createdAt->format('F d, Y') }} 37 | 38 |├── .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 |
tempestphp/tempest-framework
.
12 | tempest/console
5 | tempest/console
16 | tempest/console
38 | {php}#[ConsoleCommand]
40 | {php}AppConfig
72 | {php}ConsoleApplication::boot()
74 | ' . $parsed . ''; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Markdown/HandleParser.php: -------------------------------------------------------------------------------- 1 | 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 | getLevel(); 23 | $attrs = $node->data->get('attributes'); 24 | $slug = new ImmutableString($childRenderer->renderNodes($node->children())) 25 | ->stripTags() 26 | ->kebab() 27 | ->toString(); 28 | 29 | $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 | [\w]+)}(?
.*)/', $node->getLiteral(), $match);
27 |
28 | $language = $match['match'] ?? 'php';
29 | $code = $match['code'] ?? $node->getLiteral();
30 |
31 | return '' . $this->highlighter->parse($code, $language) . '
';
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Markdown/LinkRenderer.php:
--------------------------------------------------------------------------------
1 | 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 | 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 | 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 | 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 | 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 | 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 | '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 | 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 ', $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 | 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 | eventClass)->callStatic('unserialize', $this->payload);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/StoredEvents/StoredEventConfig.php:
--------------------------------------------------------------------------------
1 | $projectors */
9 | public array $projectors = [],
10 | ) {
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/StoredEvents/StoredEventMiddleware.php:
--------------------------------------------------------------------------------
1 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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(<<= :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(<<= :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 | 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 | 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 | count += 1;
21 |
22 | return $this;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Web/Analytics/VisitsPerDay/VisitsPerDayProjector.php:
--------------------------------------------------------------------------------
1 | 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 | 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 | count += 1;
21 |
22 | return $this;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Web/Analytics/VisitsPerHour/VisitsPerHourProjector.php:
--------------------------------------------------------------------------------
1 | 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 | toString();
11 | ?>
12 |
13 |
14 |
15 | {{ $title }}
16 |
17 |
18 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/Web/Analytics/stats.view.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Tempest Stats
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/Web/Blog/Author.php:
--------------------------------------------------------------------------------
1 | '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 | 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 | repository->all() as $post) {
19 | yield ['slug' => $post->slug];
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Web/Blog/BlogIndexer.php:
--------------------------------------------------------------------------------
1 | map(function (string $path) {
27 | $markdown = $this->markdown->convert(file_get_contents($path));
28 | preg_match('/\d+-\d+-\d+-(?.*)\.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 | slug);
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Web/Blog/BlogRepository.php:
--------------------------------------------------------------------------------
1 |
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+-(?.*)\.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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Blog
13 |
14 | Read the latest news and announcements about Tempest, from framework updates to real-world applications and expert insights.
15 |
16 |
17 |
18 |
19 | RSS
20 |
21 |
22 |
23 |
24 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/Web/Blog/rss.view.php:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | https://tempestphp.com/rss
7 |
8 |
9 | = date('c') ?>
10 |
11 |
12 |
13 | title ?> ]]>
14 |
15 |
16 |
17 | = $post->uri ?>
18 |
19 |
20 |
21 |
22 |
23 | content ?> ]]>
24 |
25 | = $post->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 |
7 |
19 |
22 |
23 |
24 |
25 |
26 |
27 | = $code ?>
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/Web/Code/ellison.view.php:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
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 | = $this->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 |
19 |
20 |
21 |
22 |
23 | {{ title }}
24 |
25 |
26 | {{ title }}
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/Web/CommandPalette/command-palette.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
24 |
25 |
31 |
32 |
36 |
37 |
38 |
39 | No result. Try another query.
40 |
41 |
42 | Type something to search.
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | {{ category.title }}
51 |
52 |
53 | handleCommand(item, e)"
61 | >
62 |
63 |
64 |
65 |
66 |
82 |
83 |
84 |
85 | {{ item.title }}
86 |
87 |
88 |
89 |
90 |
91 |
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 |
5 |
6 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/Web/Homepage/x-home-section.view.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {{ $heading }}
8 |
9 |
10 | {{ $paragraph }}
11 |
12 |
13 |
14 |
15 | {{ $linkLabel }}
16 |
17 |
18 |
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 |
13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {{ $latest_release }}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
56 |
57 |
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 |
--------------------------------------------------------------------------------