diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c420b99 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 + +[*.{js,vue}] +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..2f477ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,33 @@ +name: '🐛 Bug Report' +description: "Found a bug or something that's not quite working like it should? Open an issue." +labels: [bug] +body: +- type: markdown + attributes: + value: Thanks for taking the time to submit a bug report! Please fill out the fields with as much detail as possible, so we can get to the bottom of the issue as quickly as possible. +- type: textarea + attributes: + label: Description + description: "What's happening? What are you expecting to happen?" + validations: + required: true +- type: textarea + attributes: + label: Steps to reproduce + placeholder: | + 1. Do this thing... + 2. Then do that thing... + 3. And finally, do something else... + validations: + required: true +- type: textarea + attributes: + label: Environment + description: Copy & paste the output of `php please support:details` here. + validations: + required: true +- type: input + attributes: + label: Typesense Version + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d58b110 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: '🙏 Help' + url: https://github.com/statamic-rad-pack/typesense/discussions/new + about: 'Have a question? Need help troubleshooting? Open a discussion.' + - name: '💡 Feature Requests' + url: https://github.com/statamic-rad-pack/typesense/discussions/new + about: 'Have any idea for a new feature or enhancements to an existing one? Open a discussion.' diff --git a/.github/workflows/pint-fix.yml b/.github/workflows/pint-fix.yml new file mode 100644 index 0000000..0420249 --- /dev/null +++ b/.github/workflows/pint-fix.yml @@ -0,0 +1,28 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + fix-php-code-styling: + runs-on: ubuntu-latest + if: github.repository_owner == 'statamic-rad-pack' + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@1.0.0 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Fix styling diff --git a/.github/workflows/pint-lint.yml b/.github/workflows/pint-lint.yml new file mode 100644 index 0000000..87a91b0 --- /dev/null +++ b/.github/workflows/pint-lint.yml @@ -0,0 +1,20 @@ +name: Lint PHP code style issues + +on: + pull_request: + paths: + - '**.php' + +jobs: + lint-php-code-styling: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Check PHP code style issues + uses: aglipanci/laravel-pint-action@1.0.0 + with: + testMode: true + verboseMode: true diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..f435256 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,75 @@ +name: Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ${{ matrix.os }} + + services: + typesense: + image: typesense/typesense:27.0.rc21 + ports: + - 8108:8108/tcp + volumes: + - /tmp/typesense-server-data:/data + env: + TYPESENSE_DATA_DIR: /data + TYPESENSE_API_KEY: xyz + TYPESENSE_ENABLE_CORS: true + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: [8.2, 8.1] + laravel: [10.*, 9.*] + statamic: [4.*, 3.4.*, 3.3.*] + dependency-version: [prefer-stable] + include: + - laravel: 10.* + testbench: 8.* + - laravel: 9.* + testbench: 7.* + exclude: + - laravel: 10.* + statamic: 3.4.* + - laravel: 10.* + statamic: 3.3.* + php: 8.2 + - laravel: 10.* + statamic: 3.3.* + php: 8.1 + - laravel: 9.* + statamic: 3.3.* + php: 8.2 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - S${{ matrix.statamic }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: fileinfo, dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Setup Problem Matches + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "statamic/cms:${{ matrix.statamic }}" "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench-core:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..b9a0438 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,29 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + compare-url-target-revision: ${{ github.event.release.target_commitish }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ea6620 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea +/vendor +.DS_Store +composer.lock +.php-cs-fixer.cache +build/report.junit.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aa5622d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## v1.0.0 (2024-08-19) + +1. Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8ee9a44 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via Pull Requests on [Github](https://github.com/statamic-rad-pack/typesense). + + +## Pull Requests + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - I try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **Create feature branches** - Don't ask me to pull from your master branch. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + + +## Running Tests + +``` bash +vendor/bin/phpunit +``` + + +**Happy coding**! diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..61d7517 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Statamic Rad Pack + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9620a74 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Statamic Typesense Driver + +This addon provides a [Typesense](https://typesense.org) search driver for Statamic sites. + +## Requirements + +* PHP 8.2+ +* Laravel 10+ +* Statamic 5 +* Typesense 0.2+ + +### Installation + +```bash +composer require statamic-rad-pack/typesense +``` + +Add the following variables to your env file: + +```txt +TYPESENSE_HOST=http://127.0.0.1 +TYPESENSE_API_KEY= +``` + +Add the new driver to the `statamic/search.php` config file: + +```php +'drivers' => [ + + // other drivers + + 'typesense' => [ +'drivers' => [ + + // other drivers + + 'typesense' => [ + 'client' => [ + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + ], + ], +], +``` + +You can optionally publish the config file for this package using: + +``` +php artisan vendor:publish --tag=statamic-typesense-config +``` + +### Search Settings + +Any additional settings you want to define per index can be included in the `statamic/search.php` config file. The settings will be updated when the index is created. + +```php +'articles' => [ + 'driver' => 'typesense', + 'searchables' => ['collection:articles'], + 'fields' => ['id', 'title', 'url', 'type', 'content', 'locale'], + 'settings' => [ + 'schema' => [ + /* pass an optional schema, see: https://typesense.org/docs/26.0/api/collections.html#with-pre-defined-schema + 'fields' => [ + [ + 'name' => 'company_name', + 'type' => 'string' + ], + [ + 'name' => 'num_employees', + 'type' => 'int32' + ], + [ + 'name' => 'country', + 'type' => 'string', + 'facet' => true + ], + ], */ + ], + ], +], +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e6de85f --- /dev/null +++ b/composer.json @@ -0,0 +1,64 @@ +{ + "name": "statamic-rad-pack/typesense", + "description": "typesense search driver for Statamic", + "keywords": [ + "statamic", + "typesense", + "search" + ], + "homepage": "https://github.com/statamic-rad-pack/typesense", + "license": "MIT", + "authors": [ + { + "name": "Ryan Mitchell", + "email": "ryan@thoughtcollective.com", + "homepage": "https://github.com/ryanmitchell", + "role": "Developer" + } + ], + "require": { + "php": "^8.2", + "guzzlehttp/guzzle": "^7.3", + "http-interop/http-factory-guzzle": "^1.0", + "illuminate/support": "^10.0|^11.0", + "statamic/cms": "^5.0", + "typesense/typesense-php": "^4.9", + "laravel/pint": "^1.17" + }, + "require-dev": { + "orchestra/testbench": "^8.14 || ^9.0", + "phpunit/phpunit": "^10.0" + }, + "autoload": { + "psr-4": { + "StatamicRadPack\\Typesense\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "StatamicRadPack\\Typesense\\Tests\\": "tests" + } + }, + "scripts": { + "test": "vendor/bin/phpunit --colors=always", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage", + "format": "php-cs-fixer fix --allow-risky=yes" + }, + "config": { + "allow-plugins": { + "pixelfear/composer-dist-plugin": true, + "php-http/discovery": true + } + }, + "extra": { + "laravel": { + "providers": [ + "StatamicRadPack\\Typesense\\ServiceProvider" + ] + }, + "statamic": { + "name": "Typesense", + "description": "Typesense search driver for Statamic" + } + } +} diff --git a/config/statamic-typesense.php b/config/statamic-typesense.php new file mode 100644 index 0000000..b8d3706 --- /dev/null +++ b/config/statamic-typesense.php @@ -0,0 +1,9 @@ + 100, + +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c789a53 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + tests + + + + + + + + + + + + + + + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..ce1bb7f --- /dev/null +++ b/pint.json @@ -0,0 +1,11 @@ +{ + "preset": "laravel", + "rules": { + "class_attributes_separation": { + "elements": { + "method": "one" + } + }, + "psr_autoloading": true + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..012c925 --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,35 @@ +mergeConfigFrom(__DIR__.'/../config/statamic-typesense.php', 'statamic-typesense'); + + if ($this->app->runningInConsole()) { + + $this->publishes([ + __DIR__.'/../config/statamic-typesense.php' => config_path('statamic-typesense.php'), + ], 'statamic-typesense-config'); + + } + + Search::extend('typesense', function (Application $app, array $config, $name, $locale = null) { + $client = new Client($config['client'] ?? []); + + return $app->makeWith(Typesense\Index::class, [ + 'client' => $client, + 'name' => $name, + 'config' => $config, + 'locale' => $locale, + ]); + }); + } +} diff --git a/src/Typesense/Index.php b/src/Typesense/Index.php new file mode 100644 index 0000000..56968ee --- /dev/null +++ b/src/Typesense/Index.php @@ -0,0 +1,153 @@ +client = $client; + + parent::__construct($name, $config, $locale); + } + + public function search($query) + { + return (new Query($this))->query($query); + } + + public function insert($document) + { + return $this->insertMultiple(collect([$document])); + } + + public function insertMultiple($documents) + { + $documents + ->chunk(config('statamic-typesense.insert_chunk_size', 100)) + ->each(function ($documents, $index) { + $documents = $documents + ->filter() + ->map(fn ($document) => array_merge( + $this->searchables()->fields($document), + $this->getDefaultFields($document), + )) + ->values() + ->toArray(); + + $this->insertDocuments(new Documents($documents)); + }); + + return $this; + } + + public function delete($document) + { + $this->getOrCreateIndex()->documents[$document->getSearchReference()]?->delete(); + } + + public function exists() + { + try { + $this->getOrCreateIndex(); + + return true; + } catch (\Throwable $e) { + return false; + } + } + + protected function insertDocuments(Documents $documents) + { + $this->getOrCreateIndex()->documents->import($documents->all(), ['action' => 'upsert']); + } + + protected function deleteIndex() + { + $this->getOrCreateIndex()->delete(); + } + + public function update() + { + $this->deleteIndex(); + + $this->getOrCreateIndex(); + + $this->searchables()->lazy()->each(fn ($searchables) => $this->insertMultiple($searchables)); + + return $this; + } + + public function searchUsingApi($query, array $options = ['query_by' => ['title']]): Collection + { + $options['q'] = $query; + + $searchResults = $this->getOrCreateIndex()->documents->search($options); + + return collect($searchResults['hits'] ?? []) + ->map(function ($result, $i) { + $result['document']['search_score'] = (int) ($result['text_match'] ?? 0); + + return $result['document']; + }); + } + + public function getOrCreateIndex() + { + $collection = $this->client->getCollections()->{$this->name}; + + // Determine if the collection exists in Typesense... + try { + $collection->retrieve(); + + // No error means this collection exists on the server... + $collection->setExists(true); + + return $collection; + } catch (TypesenseClientError $e) { + + } + + $schema = Arr::get($this->config, 'settings.schema', []); + $schema['name'] = $this->name; + + if (! isset($schema['fields'])) { + $schema['fields'] = [ + ['name' => '.*', 'type' => 'auto'], + ]; + } + + $this->client->getCollections()->create($schema); + + $collection->setExists(true); + + return $collection; + } + + private function getDefaultFields(Searchable $entry): array + { + return [ + 'id' => $entry->getSearchReference(), + ]; + } + + public function getCount() + { + return $this->getOrCreateIndex()->retrieve()['num_documents'] ?? 0; + } + + public function client() + { + return $this->client; + } +} diff --git a/src/Typesense/Query.php b/src/Typesense/Query.php new file mode 100644 index 0000000..7276557 --- /dev/null +++ b/src/Typesense/Query.php @@ -0,0 +1,13 @@ +index->searchUsingApi($query); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..9ca79d5 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,49 @@ +set('statamic.search.drivers.typesense', [ + 'client' => [ + 'api_key' => env('TYPESENSE_API_KEY', 'xyz'), + 'nodes' => [ + [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + ], + 'nearest_node' => [ + 'host' => env('TYPESENSE_HOST', 'localhost'), + 'port' => env('TYPESENSE_PORT', '8108'), + 'path' => env('TYPESENSE_PATH', ''), + 'protocol' => env('TYPESENSE_PROTOCOL', 'http'), + ], + 'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2), + 'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30), + 'num_retries' => env('TYPESENSE_NUM_RETRIES', 3), + 'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1), + ], + ]); + + // add typesense index + $app['config']->set('statamic.search.indexes.typesense_index', [ + 'driver' => 'typesense', + 'searchables' => ['collection:pages'], + ]); + } +} diff --git a/tests/Unit/IndexTest.php b/tests/Unit/IndexTest.php new file mode 100644 index 0000000..9c738e9 --- /dev/null +++ b/tests/Unit/IndexTest.php @@ -0,0 +1,111 @@ +assertInstanceOf(Client::class, $index->client()); + } + + #[Test] + public function it_adds_documents_to_the_index() + { + $collection = Facades\Collection::make() + ->handle('pages') + ->title('Pages') + ->save(); + + $entry1 = Facades\Entry::make() + ->id('test-2') + ->collection('pages') + ->data(['title' => 'Entry 1']) + ->save(); + + $entry2 = Facades\Entry::make() + ->id('test-1') + ->collection('pages') + ->data(['title' => 'Entry 2']) + ->save(); + + $index = Facades\Search::index('typesense_index'); + + $export = collect(json_decode('['.str_replace("\n", ',', $index->getOrCreateIndex()->documents->export()).']'))->pluck('id'); + + $this->assertContains('entry::test-1', $export); + $this->assertContains('entry::test-2', $export); + } + + #[Test] + public function it_updates_documents_to_the_index() + { + $collection = Facades\Collection::make() + ->handle('pages') + ->title('Pages') + ->save(); + + $entry1 = Facades\Entry::make() + ->id('test-2') + ->collection('pages') + ->data(['title' => 'Entry 1']) + ->save(); + + $entry2 = tap(Facades\Entry::make() + ->id('test-1') + ->collection('pages') + ->data(['title' => 'Entry 2'])) + ->save(); + + $index = Facades\Search::index('typesense_index'); + + $export = collect(json_decode('['.str_replace("\n", ',', $index->getOrCreateIndex()->documents->export()).']'))->pluck('title'); + + $this->assertContains('Entry 1', $export); + $this->assertContains('Entry 2', $export); + + $entry2->merge(['title' => 'Entry 2 Updated'])->save(); + + $export = collect(json_decode('['.str_replace("\n", ',', $index->getOrCreateIndex()->documents->export()).']'))->pluck('title'); + + $this->assertContains('Entry 2 Updated', $export); + } + + #[Test] + public function it_removes_documents_from_the_index() + { + $collection = Facades\Collection::make() + ->handle('pages') + ->title('Pages') + ->save(); + + $entry1 = Facades\Entry::make() + ->id('test-2') + ->collection('pages') + ->data(['title' => 'Entry 1']) + ->save(); + + $entry2 = tap(Facades\Entry::make() + ->id('test-1') + ->collection('pages') + ->data(['title' => 'Entry 2'])) + ->save(); + + $entry2->delete(); + + $index = Facades\Search::index('typesense_index'); + + $export = collect(json_decode('['.str_replace("\n", ',', $index->getOrCreateIndex()->documents->export()).']'))->pluck('id'); + + $this->assertNotContains('entry::test-1', $export); + $this->assertContains('entry::test-2', $export); + } +} diff --git a/tests/__fixtures__/content/collections/pages.yaml b/tests/__fixtures__/content/collections/pages.yaml new file mode 100644 index 0000000..57e3f18 --- /dev/null +++ b/tests/__fixtures__/content/collections/pages.yaml @@ -0,0 +1,2 @@ +title: Pages +revisions: false diff --git a/tests/__fixtures__/content/collections/pages/test-2.md b/tests/__fixtures__/content/collections/pages/test-2.md new file mode 100644 index 0000000..a3cbc9c --- /dev/null +++ b/tests/__fixtures__/content/collections/pages/test-2.md @@ -0,0 +1,5 @@ +--- +id: test-2 +blueprint: page +title: 'Entry 1' +---