├── .gitignore ├── .phpunit.cache └── test-results ├── .prettierrc ├── README.md ├── composer.json ├── config └── seotamic.php ├── package-lock.json ├── package.json ├── phpunit.xml ├── postcss.config.js ├── resources ├── css │ └── cp.css ├── dist │ └── build │ │ ├── assets │ │ ├── cp-BbkQ3t6Y.css │ │ └── cp-D84CPrby.js │ │ └── manifest.json ├── js │ ├── components │ │ ├── SeotamicMetaFieldtype.vue │ │ ├── SeotamicSearchPreview.vue │ │ ├── SeotamicSocialFieldtype.vue │ │ ├── SeotamicSocialPreview.vue │ │ └── seotamic │ │ │ ├── ButtonGroup.vue │ │ │ ├── Heading.vue │ │ │ ├── SearchPreview.vue │ │ │ └── SocialPreview.vue │ ├── cp.js │ └── helpers │ │ └── debounce.js ├── lang │ └── en │ │ ├── general.php │ │ ├── seo.php │ │ └── social.php ├── screenshots │ ├── meta_preview.png │ └── social_preview.png └── views │ ├── all.antlers.html │ ├── cp │ └── settings.blade.php │ ├── partials │ ├── _canonical.antlers.html │ ├── _general.antlers.html │ ├── _og.antlers.html │ ├── _related.antlers.html │ ├── _robots.antlers.html │ └── _twitter.antlers.html │ └── sitemap.antlers.html ├── src ├── Commands │ └── MigrateCommand.php ├── FieldTypes │ ├── SeotamicMeta.php │ ├── SeotamicSearchPreview.php │ ├── SeotamicSocial.php │ ├── SeotamicSocialPreview.php │ └── SeotamicType.php ├── File │ └── File.php ├── GraphQL │ ├── SeotamicMetaField.php │ ├── SeotamicMetaType.php │ ├── SeotamicSocialField.php │ └── SeotamicSocialType.php ├── Http │ └── Controllers │ │ ├── SettingsController.php │ │ └── SitemapController.php ├── ServiceProvider.php ├── SitemapSubscriber.php ├── Subscriber.php ├── Tags │ ├── SeotamicTags.php │ └── Tags.php └── routes │ ├── cp.php │ └── web.php ├── tailwind.config.js ├── tests ├── BaseTest.php ├── MetaTest.php ├── SitemapTest.php ├── SocialTest.php ├── TestCase.php └── __fixtures__ │ ├── content │ ├── assets │ │ ├── .gitkeep │ │ └── assets.yaml │ ├── collections │ │ ├── .gitkeep │ │ ├── pages.yaml │ │ ├── pages │ │ │ ├── en │ │ │ │ ├── contact.md │ │ │ │ ├── gallery.md │ │ │ │ ├── home.md │ │ │ │ └── protected_entity.md │ │ │ ├── it │ │ │ │ ├── contact.md │ │ │ │ ├── gallery.md │ │ │ │ └── home.md │ │ │ └── sl │ │ │ │ ├── contact.md │ │ │ │ ├── gallery.md │ │ │ │ └── home.md │ │ ├── protected.yaml │ │ └── protected │ │ │ └── en │ │ │ └── test.md │ ├── globals │ │ └── .gitkeep │ ├── navigation │ │ ├── .gitkeep │ │ └── menu.yaml │ ├── seotamic_en_US.yaml │ ├── taxonomies │ │ └── .gitkeep │ └── trees │ │ ├── collections │ │ ├── en │ │ │ └── pages.yaml │ │ ├── it │ │ │ └── pages.yaml │ │ └── sl │ │ │ └── pages.yaml │ │ └── navigation │ │ ├── en │ │ └── menu.yaml │ │ ├── it │ │ └── menu.yaml │ │ └── sl │ │ └── menu.yaml │ └── sites.yaml └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | composer.lock 4 | vendor 5 | .phpunit.result.cache 6 | node_modules 7 | -------------------------------------------------------------------------------- /.phpunit.cache/test-results: -------------------------------------------------------------------------------- 1 | {"version":1,"defects":{"Cnj\\Seotamic\\Tests\\SitemapTest::test_it_returns_a_working_sitemap":8,"Cnj\\Seotamic\\Tests\\SitemapTest::test_it_returns_404_when_sitemap_is_disabled":7,"Cnj\\Seotamic\\Tests\\MetaTest::test_it_returns_a_working_sitemap":8,"Cnj\\Seotamic\\Tests\\MetaTest::test_seotamic_title_is_returned_correctly":7,"Cnj\\Seotamic\\Tests\\MetaTest::test_seotamic_custom_title_is_returned_correctly":8,"Cnj\\Seotamic\\Tests\\MetaTest::test_meta_has_all_the_expected_keys":8,"Cnj\\Seotamic\\Tests\\SocialTest::test_meta_has_all_the_expected_keys":8},"times":{"Cnj\\Seotamic\\Tests\\ExampleTest::test_that_true_is_true":0.006,"Cnj\\Seotamic\\Tests\\SitemapTest::test_it_returns_404_when_sitemap_is_disabled":0.01,"Cnj\\Seotamic\\Tests\\SitemapTest::test_it_returns_a_working_sitemap":0.011,"Cnj\\Seotamic\\Tests\\BaseTest::test_that_true_is_true":0.003,"Cnj\\Seotamic\\Tests\\MetaTest::test_it_returns_a_working_sitemap":0.038,"Cnj\\Seotamic\\Tests\\MetaTest::test_meta_has_all_the_expected_keys":0.022,"Cnj\\Seotamic\\Tests\\MetaTest::test_seotamic_title_is_returned_correctly":0.011,"Cnj\\Seotamic\\Tests\\MetaTest::test_seotamic_custom_title_is_returned_correctly":0.036,"Cnj\\Seotamic\\Tests\\SocialTest::test_meta_has_all_the_expected_keys":0.013}} -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **PRO Version:** If you are having issues with the license, make sure to add `'cnj/seotamic' => 'pro'` in the `config/statamic/editions.php` `addons` array. 2 | 3 | # Seotamic - Statamic SEO Addon 4 | 5 | Statamic v5 only. For Statamic v4 use the 4.\* releases and for Statamic v3 use the 3.\* releases. Automatically adds a SEO tab to all your collection entries where you can fine tune SEO for every entry. Works perfectly with Antlers, Blade and in headless mode (PRO edition) with the Statamic REST API or GraphQL integration out of the box. 6 | 7 | ## Quick Antlers usage sample 8 | 9 | ```php 10 | {{ seotamic }} 11 | ``` 12 | 13 | Generates the whole array of SEO settings: 14 | 15 | ```html 16 | My Page Title 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ... 25 | ``` 26 | 27 | # Version 5 changes 28 | 29 | Seotamic v5.\* is compatible with Statamic v5.0+. 30 | 31 | # Version 4.1 changes 32 | 33 | Seotamic v4.1 is a minor update that adds related meta tags. The related meta tags are used to link to other pages that are related to the current page in a multisite scenario. They are available as tags and can be used in the Antlers or Blade templates. In the PRO query mode, the related meta tags are also available in the REST API and GraphQL. 34 | 35 | # Version 4 changes 36 | 37 | Statamic v4 compatibility. Some internal functions were changed due to how blueprints work in Statamic v4. This release breaks compatibility with Statamic v3. Upgrade to Statamic v4 before upgrading to this version. Upgrade path from SEOtamic v2 is still the same. 38 | 39 | # Version 3 changes 40 | 41 | Version 3 has breaking changes. If you update from version 1 or 2, your global settings will not be transfered. The data layout is a bit different and so is the data on specific entries. 42 | 43 | The string tags were changed to arrays so to access the data you need to use the `:meta` or `:social` prefixes. For example `{{ seotamic:title }}` becomes `{{ seotamic:meta:title }}`. 44 | 45 | ## Migration from version 2 46 | 47 | If you are migrating from version 2, you can use the following command to migrate your data: 48 | 49 | ```sh 50 | php artisan seotamic:migrate 51 | ``` 52 | 53 | This will migrate your global settings and all the entries. It will also remove the old fields from the entries. Make sure to backup your data before running the command. 54 | 55 | # Installation 56 | 57 | Include the package with composer: 58 | 59 | ```sh 60 | composer require cnj/seotamic 61 | ``` 62 | 63 | The package requires Laravel 9+ and PHP 8.1+. It will auto register. 64 | 65 | The SEO & Social section tab will appear on all collection entries automatically. 66 | 67 | ## Configuration (optional) 68 | 69 | You can override the default options by publishing the configuration: 70 | 71 | ``` 72 | php artisan vendor:publish --provider="Cnj\Seotamic\ServiceProvider" --tag=config 73 | ``` 74 | 75 | This will copy the default config file to `config/seotamic.php'. 76 | 77 | If you need to change the default assets container, make sure to apply the change in the Blueprints as well. 78 | 79 | # Usage 80 | 81 | Usage is fairly simple and straight forward. You can visit the global Settings by following the Seotamic link on the navigation in the CP. Make sure to follow the instructions on each field. 82 | 83 | ![Meta preview](./resources/screenshots/meta_preview.png) 84 | 85 | After this you can fine tune the output of each collection entry by editing the SEO settings under the entry's SEO tab. From version 3 you can also preview the output of the meta and social settings. The previews should give an accurate representation of the output. 86 | 87 | ![Social preview](./resources/screenshots/social_preview.png) 88 | 89 | ## Antlers 90 | 91 | There are several antler tags available, the easiest is to just include the do everything base tag in the head of your layout: 92 | 93 | ``` 94 | {{ seotamic }} 95 | ``` 96 | 97 | If you need more control you can manually get each part of the output by using: 98 | 99 | ``` 100 | {{ seotamic:meta:title }} 101 | {{ seotamic:meta:description }} 102 | {{ seotamic:meta:canonical }} 103 | {{ seotamic:meta:robots }} 104 | {{ seotamic:meta:related }} 105 | ``` 106 | 107 | This will return strings, so you need to wrap them in the appropriate tags, ie: 108 | 109 | ```html 110 | {{ seotamic:meta:title }} 111 | ``` 112 | 113 | Social ones will still return everything with these tags (the general one returns this as well) 114 | 115 | ``` 116 | {{ seotamic:og }} 117 | {{ seotamic:twitter }} 118 | ``` 119 | 120 | If you need more control you can manually get each part of the output by using: 121 | 122 | ``` 123 | {{ seotamic:social:title }} 124 | {{ seotamic:social:description }} 125 | {{ seotamic:social:image }} 126 | {{ seotamic:social:site_name }} 127 | ``` 128 | 129 | This will return strings, so you need to wrap them in the appropriate tags. 130 | 131 | ## Blade 132 | 133 | Similarly to the Antlers usage, you can use the same tags using Blade: 134 | 135 | ```php 136 | {!! Statamic::tag('seotamic')->context(get_defined_vars()) !!} 137 | ``` 138 | 139 | It works similary to the Antlers tags, so you can use single values as well. 140 | 141 | ## Headless (PRO) 142 | 143 | Headless use is straightforward. If using the REST API or GraphQL, the entry will include three Seotamic fields: `seotamic_meta`, `seotamic_social` with the prefilled SEO data. 144 | 145 | You can also set the base canonical url in the config file by setting a value for `headless_mode`. The default value is `false`, but setting it to `https://mydomain.com` will change all canonical urls. The images will NOT use this base url, as they are still served from Statamic. 146 | 147 | **Headless usage is supported only for the PRO version.** 148 | 149 | ## Sitemap (PRO) 150 | 151 | Sitemap is autogenerated under the `/sitemap.xml` url if the sitemap config options are set to true (default). 152 | 153 | **Sitemap is supported only for the PRO version.** 154 | 155 | ## Dynamic OG Image injection 156 | 157 | In projects where you want the OG Image to be dynamic, for 158 | now you can use this ViewModel and inject it to your collection in order to 159 | dynamically assign the OG Image. 160 | 161 | ```php 162 | cascade->get('seotamic_social'); 173 | 174 | if ($social) { 175 | return [ ...$social, 'image' => 'https://myimageurl.com/image.jpg', ]; 176 | } 177 | 178 | return []; 179 | } 180 | } 181 | ``` 182 | 183 | In the example above we use a hardcoded image url which you can change to suit your usecase. Then in your collections you just have to inject the ViewModel. 184 | 185 | ```yaml 186 | title: Posts 187 | inject: 188 | view_model: App\ViewModels\OgImage 189 | ``` 190 | 191 | # Credits 192 | 193 | This package was built by [CNJ Digital](https://www.cnj.si/). 194 | 195 | # Version 3, 4 and 5 License 196 | 197 | Version 3 and 4 are a commercial addon for Statamic. It is open source but not free to use. You can purchase a license at [Statamic Marketplace](https://statamic.com/marketplace/addons/seotamic). 198 | 199 | # Version 2 and 1 License 200 | 201 | Version 2 and 1 were licensed under the MIT License. 202 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cnj/seotamic", 3 | "description": "Simple SEO addon for Statamic", 4 | "type": "statamic-addon", 5 | "license": "proprietary", 6 | "require": { 7 | "php": "^8.2", 8 | "statamic/cms": "^5.0.0" 9 | }, 10 | "require-dev": { 11 | "orchestra/testbench": "^9.0" 12 | }, 13 | "autoload-dev": { 14 | "psr-4": { 15 | "Cnj\\Seotamic\\Tests\\": "tests" 16 | } 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "Cnj\\Seotamic\\": "src" 21 | } 22 | }, 23 | "authors": [ 24 | { 25 | "name": "Martin Koterle", 26 | "email": "martin@cnj.si" 27 | } 28 | ], 29 | "extra": { 30 | "statamic": { 31 | "name": "Seotamic", 32 | "description": "Simple SEO addon for Statamic", 33 | "editions": [ 34 | "lite", 35 | "pro" 36 | ] 37 | }, 38 | "laravel": { 39 | "providers": [ 40 | "Cnj\\Seotamic\\ServiceProvider" 41 | ] 42 | } 43 | }, 44 | "config": { 45 | "allow-plugins": { 46 | "pixelfear/composer-dist-plugin": true 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /config/seotamic.php: -------------------------------------------------------------------------------- 1 | 'seotamic', 9 | 10 | // Social images asset container. It must exist in Statamic. 11 | // A sensible default is set. 12 | 'container' => 'assets', 13 | 14 | // A list of blueprints where the SEO fields will not be injected. 15 | // By default this list is empty. 16 | 'ignore_blueprints' => [], 17 | 18 | // Do we want to add a /sitemap.xml to the site? 19 | 'sitemap' => true, 20 | 21 | // If set (ie https://test.domain.com) this base url will replace the 22 | // app url for canonical and social url tags. Image links will still 23 | // be linked to the CMS domain. 24 | // 25 | // Defautl value: false 26 | 'headless_mode' => false, 27 | 28 | // Default recommended field lengths. 29 | // They are just guidelines and can be ignored on the frontend 30 | 'meta_title_length' => 60, 31 | 'meta_description_length' => 160, 32 | 'social_title_length' => 60, 33 | 'social_description_length' => 60, 34 | 35 | // Key value store of blueprint names and the field name that contains the 36 | // social image. This is used to override the default social image behaviour 37 | // WARNING: API not final, subject to change in the future, do not use in production 38 | 'social_image_override' => [ 39 | 'sample_blueprint' => "sample_field_name" 40 | ], 41 | ]; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build" 6 | }, 7 | "devDependencies": { 8 | "@rollup/plugin-inject": "^5.0.5", 9 | "@vitejs/plugin-vue2": "^2.2.0", 10 | "autoprefixer": "^10.4.19", 11 | "laravel-vite-plugin": "^1.2.0", 12 | "postcss": "^8.4.23", 13 | "tailwindcss": "^3.3.2", 14 | "vite": "^6.2.6" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /resources/css/cp.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /resources/dist/build/assets/cp-BbkQ3t6Y.css: -------------------------------------------------------------------------------- 1 | *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.seotamic-mb-0\.5{margin-bottom:.125rem}.seotamic-mb-2{margin-bottom:.5rem}.seotamic-mt-2{margin-top:.5rem}.seotamic-mt-8{margin-top:2rem}.seotamic-mt-\[3px\]{margin-top:3px}.seotamic-mt-\[5px\]{margin-top:5px}.seotamic-line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.seotamic-flex{display:flex}.seotamic-h-\[261px\]{height:261px}.seotamic-h-auto{height:auto}.seotamic-h-px{height:1px}.seotamic-w-full{width:100%}.seotamic-max-w-\[500px\]{max-width:500px}.seotamic-max-w-\[600px\]{max-width:600px}.seotamic-flex-col{flex-direction:column}.seotamic-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.seotamic-rounded-\[3px\]{border-radius:3px}.seotamic-border{border-width:1px}.seotamic-border-\[\#dadde1\]{--tw-border-opacity: 1;border-color:rgb(218 221 225 / var(--tw-border-opacity, 1))}.seotamic-bg-\[\#f2f3f5\]{--tw-bg-opacity: 1;background-color:rgb(242 243 245 / var(--tw-bg-opacity, 1))}.seotamic-bg-blue-300{--tw-bg-opacity: 1;background-color:rgb(147 197 253 / var(--tw-bg-opacity, 1))}.seotamic-bg-gray-300{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1))}.seotamic-bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.seotamic-p-3{padding:.75rem}.\!seotamic-px-3{padding-left:.75rem!important;padding-right:.75rem!important}.\!seotamic-py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.seotamic-px-3{padding-left:.75rem;padding-right:.75rem}.seotamic-py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.seotamic-text-\[13px\]{font-size:13px}.seotamic-text-base{font-size:1rem;line-height:1.5rem}.seotamic-text-sm{font-size:.875rem;line-height:1.25rem}.seotamic-text-xl{font-size:1.25rem;line-height:1.75rem}.seotamic-text-xs{font-size:.75rem;line-height:1rem}.seotamic-font-bold{font-weight:700}.seotamic-font-semibold{font-weight:600}.seotamic-uppercase{text-transform:uppercase}.seotamic-leading-\[18px\]{line-height:18px}.seotamic-leading-\[20px\]{line-height:20px}.seotamic-leading-\[26px\]{line-height:26px}.seotamic-leading-none{line-height:1}.seotamic-tracking-wider{letter-spacing:.05em}.seotamic-text-\[\#1a0dab\]{--tw-text-opacity: 1;color:rgb(26 13 171 / var(--tw-text-opacity, 1))}.seotamic-text-\[\#1c2e36\]{--tw-text-opacity: 1;color:rgb(28 46 54 / var(--tw-text-opacity, 1))}.seotamic-text-\[\#1d2129\]{--tw-text-opacity: 1;color:rgb(29 33 41 / var(--tw-text-opacity, 1))}.seotamic-text-\[\#202124\]{--tw-text-opacity: 1;color:rgb(32 33 36 / var(--tw-text-opacity, 1))}.seotamic-text-\[\#4d5156\]{--tw-text-opacity: 1;color:rgb(77 81 86 / var(--tw-text-opacity, 1))}.seotamic-text-\[\#606770\]{--tw-text-opacity: 1;color:rgb(96 103 112 / var(--tw-text-opacity, 1))}.seotamic-text-\[\#737f8c\]{--tw-text-opacity: 1;color:rgb(115 127 140 / var(--tw-text-opacity, 1))}.seotamic-shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)} 2 | -------------------------------------------------------------------------------- /resources/dist/build/assets/cp-D84CPrby.js: -------------------------------------------------------------------------------- 1 | function s(i,t,e,a,l,o,u,p){var n=typeof i=="function"?i.options:i;return t&&(n.render=t,n.staticRenderFns=e,n._compiled=!0),{exports:i,options:n}}const _={props:{title:{type:String,required:!0,default:""}}};var h=function(){var t=this,e=t._self._c;return e("div",[e("div",{staticClass:"seotamic-text-sm seotamic-font-bold seotamic-text-[#1c2e36]"},[t._v(" "+t._s(t.title)+" ")]),e("div",{staticClass:"seotamic-text-[13px] seotamic-text-[#737f8c] seotamic-mb-2"},[t._t("default")],2)])},f=[],x=s(_,h,f);const c=x.exports,g={props:{previewTitle:{type:String,required:!0,default:""},permalink:{type:String,required:!1,default:""},domain:{type:String,required:!0,default:""},title:{type:String,required:!0,default:""},description:{type:String,required:!0,default:""}},methods:{truncate(i,t=160){return!i||i.length<=t?i:i.slice(0,t)+" …"}},computed:{url(){return this.domain&&(this.domain.startsWith("https://")||this.domain.startsWith("http://"))?this.domain:"https://"+this.domain}}};var b=function(){var t=this,e=t._self._c;return e("div",[t.previewTitle?e("div",{staticClass:"seotamic-text-xs seotamic-uppercase seotamic-font-bold seotamic-tracking-wider"},[t._v(" "+t._s(t.previewTitle)+" ")]):t._e(),t.permalink?e("div",{staticClass:"seotamic-mt-2"},[e("a",{staticClass:"text-sm underline text-blue hover:text-blue-dark",attrs:{href:"https://pagespeed.web.dev/report?url="+t.permalink,target:"_blank",rel:"noopener noreferrer"}},[t._v(" Pagespeed Insights ")])]):t._e(),e("div",{staticClass:"seotamic-mt-2 seotamic-bg-white seotamic-border seotamic-shadow-sm seotamic-p-3 seotamic-flex seotamic-flex-col seotamic-max-w-[600px] seotamic-rounded-[3px]"},[e("div",{staticClass:"seotamic-text-sm seotamic-text-[#202124] seotamic-mb-0.5"},[t._v(" "+t._s(t.url)+" ")]),e("div",{staticClass:"seotamic-text-[#1a0dab] seotamic-text-xl seotamic-leading-[26px] seotamic-truncate seotamic-mt-[5px]"},[t._v(" "+t._s(t.title)+" ")]),e("div",{staticClass:"seotamic-text-sm seotamic-text-[#4d5156]"},[t._v(" "+t._s(t.truncate(t.description))+" ")])])])},y=[],w=s(g,b,y);const m=w.exports,$={components:{Heading:c,SearchPreview:m},computed:{prependExists(){return this.$attrs.meta.seotamic.title_prepend!==null},appendExists(){return this.$attrs.meta.seotamic.title_append!==null},previewTitle(){const i=this.prependExists?this.$attrs.meta.seotamic.title_prepend+" ":"",t=this.appendExists?" "+this.$attrs.meta.seotamic.title_append:"";return`${i}${this.$attrs.meta.t.demo_title}${t}`}}};var C=function(){var t=this,e=t._self._c;return e("SearchPreview",{attrs:{domain:t.$attrs.meta.seotamic.preview_domain,title:t.previewTitle,description:t.$attrs.meta.t.default_description}})},S=[],k=s($,C,S);const D=k.exports,T={props:{previewTitle:{type:String,required:!1,default:""},permalink:{type:String,required:!1,default:""},domain:{type:String,required:!0,default:""},image:{type:String,required:!0,default:""},title:{type:String,required:!0,default:""},description:{type:String,required:!0,default:""}},data(){return{showDescription:!0}},watch:{title(){this.$nextTick(()=>{this.$refs.title.clientHeight>=40?this.showDescription=!1:this.showDescription=!0})}}};var F=function(){var t=this,e=t._self._c;return e("div",{},[t.previewTitle?e("div",{staticClass:"seotamic-text-xs seotamic-uppercase seotamic-font-bold seotamic-tracking-wider"},[t._v(" "+t._s(t.previewTitle)+" ")]):t._e(),t.permalink?e("div",{staticClass:"seotamic-mt-2"},[e("a",{staticClass:"text-sm underline text-blue hover:text-blue-dark",attrs:{href:"https://developers.facebook.com/tools/debug/?q="+t.permalink,target:"_blank",rel:"noopener noreferrer"}},[t._v(" Facebook Debugger ")])]):t._e(),e("div",{staticClass:"seotamic-mt-2 seotamic-bg-[#f2f3f5] seotamic-border seotamic-border-[#dadde1] seotamic-shadow-sm seotamic-flex seotamic-flex-col seotamic-max-w-[500px] seotamic-rounded-[3px]"},[e("div",{staticClass:"seotamic-h-[261px] seotamic-w-full seotamic-bg-blue-300 relative"},[t.image?e("img",{staticClass:"absolute object-cover w-full h-full",attrs:{src:t.image}}):t._e()]),e("div",{staticClass:"seotamic-py-2.5 seotamic-px-3"},[e("div",{staticClass:"seotamic-text-xs seotamic-text-[#606770] seotamic-leading-none seotamic-uppercase"},[t._v(" "+t._s(t.domain)+" ")]),e("div",{ref:"title",staticClass:"seotamic-text-[#1d2129] seotamic-font-semibold seotamic-text-base seotamic-leading-[20px] seotamic-line-clamp-2 seotamic-mt-[5px]"},[t._v(" "+t._s(t.title)+" ")]),e("div",{directives:[{name:"show",rawName:"v-show",value:t.showDescription,expression:"showDescription"}],staticClass:"seotamic-text-sm seotamic-leading-[18px] seotamic-text-[#606770] seotamic-truncate seotamic-mt-[3px]"},[t._v(" "+t._s(t.description)+" ")])])])])},q=[],R=s(T,F,q);const d=R.exports,O={components:{SocialPreview:d},mixins:[Fieldtype]};var P=function(){var t=this,e=t._self._c;return e("SocialPreview",{attrs:{domain:t.meta.seotamic.preview_domain,title:t.meta.seotamic.social_title,image:t.meta.image,description:t.meta.seotamic.social_description}})},E=[],H=s(O,P,E);const B=H.exports,G={props:{value:{type:String,required:!0},options:{type:Array,required:!0,default:()=>[]}}};var I=function(){var t=this,e=t._self._c;return e("div",{staticClass:"button-group-fieldtype-wrapper inline-mode"},[e("div",{staticClass:"btn-group"},t._l(t.options,function(a,l){return e("button",{key:l,ref:"button",refInFor:!0,staticClass:"btn !seotamic-py-1 seotamic-text-sm seotamic-h-auto !seotamic-px-3",class:{active:t.value===a.value},attrs:{name:a.label,value:a.value},domProps:{textContent:t._s(a.label||a.value)},on:{click:function(o){return t.$emit("input",a.value)}}})}),0)])},A=[],j=s(G,I,A);const v=j.exports;function r(i,t,e=!1){let a;return function(){const l=this,o=arguments,u=function(){a=null,e||i.apply(l,o)},p=e&&!a;clearTimeout(a),a=setTimeout(u,t),p&&i.apply(l,o)}}const M={components:{ButtonGroup:v,Heading:c,SearchPreview:m},mixins:[Fieldtype],data(){return{description:this.meta.t.default_description,titleOptions:[{label:this.meta.t.label_title,value:"title"},{label:this.meta.t.label_custom,value:"custom"}],descriptionOptions:[{label:this.meta.t.label_empty,value:"empty"},{label:this.meta.t.label_custom,value:"custom"}]}},computed:{prependExists(){return this.meta.seotamic.title_prepend!==null},appendExists(){return this.meta.seotamic.title_append!==null},previewTitle(){const i=this.prependExists&&this.value.title.prepend?this.meta.seotamic.title_prepend+" ":"",t=this.appendExists&&this.value.title.append?" "+this.meta.seotamic.title_append:"";return`${i}${this.value.title.value}${t}`},previewDescription(){return this.value.description.value===""||!this.value.description.value?this.description:this.value.description.value}},watch:{"value.title.type":function(i){!this.value||!this.value.title||(i==="title"?(this.value.title.custom_value=this.value.title.value,this.value.title.value=this.meta.title):this.value.title.value=this.value.title.custom_value)},"value.description.type":function(i,t){!this.value||!this.value.description||(t==="custom"&&(this.value.description.custom_value=this.value.description.value),i==="custom"?this.value.description.value=this.value.description.custom_value:this.value.description.value="")}},created(){this.updateTitleDebounced=r(i=>{!this.value||!this.value.title||(this.value.title.value=i,this.value.title.type==="custom"&&(this.value.title.custom_value=i))},50),this.updateDescriptionDebounced=r(i=>{!this.value||!this.value.description||(this.value.description.value=i,this.value.description.type==="custom"&&(this.value.description.custom_value=i))},50)},methods:{updatePrepend(i){this.value.title.prepend=i},updateAppend(i){this.value.title.append=i}}};var N=function(){var t=this,e=t._self._c;return t.value?e("div",[e("div",[e("Heading",{attrs:{title:t.meta.t.title_title}},[t._v(" "+t._s(t.meta.t.title_instructions)+" ")]),e("ButtonGroup",{attrs:{options:t.titleOptions},model:{value:t.value.title.type,callback:function(a){t.$set(t.value.title,"type",a)},expression:"value.title.type"}}),e("div",{staticClass:"seotamic-mt-2"},[e("text-input",{ref:"title",attrs:{value:t.value.title.value,type:"text",isReadOnly:t.value.title.type!=="custom",limit:t.meta.config.meta_title_length,name:"meta_title",id:"meta_title"},on:{input:t.updateTitleDebounced}})],1),e("div",{staticClass:"seotamic-mt-2"},[e("div",{staticClass:"toggle-fieldtype-wrapper"},[e("toggle-input",{attrs:{value:t.value.title.prepend,"read-only":!t.prependExists},on:{input:t.updatePrepend}}),e("label",{staticClass:"inline-label"},[t._v(" "+t._s(t.meta.t.prepend_label)+" ")])],1)]),e("div",[e("div",{staticClass:"toggle-fieldtype-wrapper"},[e("toggle-input",{attrs:{value:t.value.title.append,"read-only":!t.appendExists},on:{input:t.updateAppend}}),e("label",{staticClass:"inline-label"},[t._v(" "+t._s(t.meta.t.append_label)+" ")])],1)])],1),e("div",{staticClass:"seotamic-mt-8"},[e("Heading",{attrs:{title:t.meta.t.description_title}},[t._v(" "+t._s(t.meta.t.description_instructions)+" ")]),e("ButtonGroup",{attrs:{options:t.descriptionOptions},model:{value:t.value.description.type,callback:function(a){t.$set(t.value.description,"type",a)},expression:"value.description.type"}}),e("div",{staticClass:"seotamic-mt-2"},[e("textarea-input",{ref:"description",attrs:{value:t.value.description.value,type:"text",isReadOnly:t.value.description.type!=="custom",limit:t.meta.config.meta_description_length,name:"meta_description",id:"meta_description"},on:{input:t.updateDescriptionDebounced}})],1)],1),e("SearchPreview",{staticClass:"seotamic-mt-8",attrs:{"preview-title":t.meta.t.preview_title,permalink:t.meta.permalink,domain:t.meta.seotamic.preview_domain,title:t.previewTitle,description:t.previewDescription}}),e("div",{staticClass:"seotamic-mt-8 seotamic-h-px seotamic-bg-gray-300"})],1):t._e()},W=[],z=s(M,N,W);const J=z.exports,K={components:{ButtonGroup:v,Heading:c,SocialPreview:d},mixins:[Fieldtype],data(){return{titleOptions:[{label:this.meta.t.label_title,value:"title"},{label:this.meta.t.label_general,value:"general"},{label:this.meta.t.label_custom,value:"custom"}],descriptionOptions:[{label:this.meta.t.label_meta,value:"meta"},{label:this.meta.t.label_general,value:"general"},{label:this.meta.t.label_custom,value:"custom"}]}},watch:{"value.title.type":function(i,t){!this.value||!this.value.title||(t==="custom"&&(this.value.title.custom_value=this.value.title.value),i==="title"?this.meta.meta.title.type==="custom"?this.value.title.value=this.meta.meta.title.value:this.value.title.value=this.meta.title:i==="general"?this.value.title.value=this.meta.seotamic.social_title:this.value.title.value=this.value.title.custom_value)},"value.description.type":function(i,t){!this.value||!this.value.description||(t==="custom"&&(this.value.description.custom_value=this.value.description.value),i==="meta"?this.meta.meta.description.type==="custom"?this.value.description.value=this.meta.meta.description.value:this.value.description.value=this.meta.seotamic.social_description:i==="general"?this.value.description.value=this.meta.seotamic.social_description:this.value.description.value=this.value.description.custom_value)}},created(){this.updateTitleDebounced=r(i=>{!this.value||!this.value.title||(this.value.title.value=i,this.value.title.type==="custom"&&(this.value.title.custom_value=i))},50),this.updateDescriptionDebounced=r(i=>{!this.value||!this.value.description||(this.value.description.value=i,this.value.description.type==="custom"&&(this.value.description.custom_value=i))},50)}};var L=function(){var t=this,e=t._self._c;return t.value?e("div",[e("div",[e("Heading",{attrs:{title:t.meta.t.title_title}},[t._v(" "+t._s(t.meta.t.title_instructions)+" ")]),e("ButtonGroup",{attrs:{options:t.titleOptions},model:{value:t.value.title.type,callback:function(a){t.$set(t.value.title,"type",a)},expression:"value.title.type"}}),e("div",{staticClass:"seotamic-mt-2"},[e("text-input",{ref:"title",attrs:{value:t.value.title.value,type:"text",isReadOnly:t.value.title.type!=="custom",limit:t.meta.config.social_title_length,name:"og_title",id:"og_title"},on:{input:t.updateTitleDebounced}})],1)],1),e("div",{staticClass:"seotamic-mt-8"},[e("Heading",{attrs:{title:t.meta.t.description_title}},[t._v(" "+t._s(t.meta.t.description_instructions)+" ")]),e("ButtonGroup",{attrs:{options:t.descriptionOptions},model:{value:t.value.description.type,callback:function(a){t.$set(t.value.description,"type",a)},expression:"value.description.type"}}),e("div",{staticClass:"seotamic-mt-2"},[e("text-input",{ref:"description",attrs:{value:t.value.description.value,type:"text",isReadOnly:t.value.description.type!=="custom",limit:t.meta.config.social_description_length,name:"description",id:"description"},on:{input:t.updateDescriptionDebounced}})],1)],1),e("SocialPreview",{staticClass:"seotamic-mt-8",attrs:{"preview-title":t.meta.t.preview_title,permalink:t.meta.permalink,domain:t.meta.seotamic.preview_domain,title:t.value.title.value,image:t.meta.social_image,description:t.value.description.value}}),e("div",{staticClass:"seotamic-mt-8 seotamic-h-px seotamic-bg-gray-300"})],1):t._e()},Q=[],U=s(K,L,Q);const X=U.exports;Statamic.booting(()=>{Statamic.component("seotamic_search_preview-fieldtype",D),Statamic.component("seotamic_social_preview-fieldtype",B),Statamic.component("seotamic_meta-fieldtype",J),Statamic.component("seotamic_social-fieldtype",X)}); 2 | -------------------------------------------------------------------------------- /resources/dist/build/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources/css/cp.css": { 3 | "file": "assets/cp-BbkQ3t6Y.css", 4 | "src": "resources/css/cp.css", 5 | "isEntry": true 6 | }, 7 | "resources/js/cp.js": { 8 | "file": "assets/cp-D84CPrby.js", 9 | "name": "cp", 10 | "src": "resources/js/cp.js", 11 | "isEntry": true 12 | } 13 | } -------------------------------------------------------------------------------- /resources/js/components/SeotamicMetaFieldtype.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 223 | -------------------------------------------------------------------------------- /resources/js/components/SeotamicSearchPreview.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 39 | -------------------------------------------------------------------------------- /resources/js/components/SeotamicSocialFieldtype.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 174 | -------------------------------------------------------------------------------- /resources/js/components/SeotamicSocialPreview.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | -------------------------------------------------------------------------------- /resources/js/components/seotamic/ButtonGroup.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 34 | -------------------------------------------------------------------------------- /resources/js/components/seotamic/Heading.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /resources/js/components/seotamic/SearchPreview.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 96 | -------------------------------------------------------------------------------- /resources/js/components/seotamic/SocialPreview.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 112 | -------------------------------------------------------------------------------- /resources/js/cp.js: -------------------------------------------------------------------------------- 1 | import SeotamicSearchPreview from "./components/SeotamicSearchPreview.vue"; 2 | import SeotamicSocialPreview from "./components/SeotamicSocialPreview.vue"; 3 | import SeotamicMetaFieldtype from "./components/SeotamicMetaFieldtype.vue"; 4 | import SeotamicSocialFieldtype from "./components/SeotamicSocialFieldtype.vue"; 5 | 6 | Statamic.booting(() => { 7 | Statamic.component( 8 | "seotamic_search_preview-fieldtype", 9 | SeotamicSearchPreview 10 | ); 11 | Statamic.component( 12 | "seotamic_social_preview-fieldtype", 13 | SeotamicSocialPreview 14 | ); 15 | Statamic.component("seotamic_meta-fieldtype", SeotamicMetaFieldtype); 16 | Statamic.component("seotamic_social-fieldtype", SeotamicSocialFieldtype); 17 | }); 18 | -------------------------------------------------------------------------------- /resources/js/helpers/debounce.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a debounced function that delays invoking the provided function 3 | * until after 'wait' milliseconds have elapsed since the last time it was invoked. 4 | * 5 | * @param {Function} func - The function to debounce 6 | * @param {number} wait - The number of milliseconds to delay 7 | * @param {boolean} [immediate=false] - Whether to invoke the function on the leading edge instead of the trailing edge 8 | * @returns {Function} - The debounced function 9 | */ 10 | export function debounce(func, wait, immediate = false) { 11 | let timeout; 12 | 13 | return function () { 14 | const context = this; 15 | const args = arguments; 16 | 17 | const later = function () { 18 | timeout = null; 19 | if (!immediate) func.apply(context, args); 20 | }; 21 | 22 | const callNow = immediate && !timeout; 23 | 24 | clearTimeout(timeout); 25 | timeout = setTimeout(later, wait); 26 | 27 | if (callNow) func.apply(context, args); 28 | }; 29 | } 30 | 31 | export default debounce; 32 | -------------------------------------------------------------------------------- /resources/lang/en/general.php: -------------------------------------------------------------------------------- 1 | 'SEOtamic', 8 | 'intro' => 'Control your SEO general settings here. Make sure to read the instructions on each input. These settings can be overidden on specific entries/pages.', 9 | 10 | 'title' => 'Settings', 11 | 12 | /** 13 | * Title Tab 14 | */ 15 | 'title_title' => 'Title', 16 | 17 | 'title_section_title' => 'Title', 18 | 'title_section_instructions' => 'You should always have a unique title tag on every page that describes the page. By default SEOtamic uses the Entry title as the title, which you can edit in the SEO settings for each page.', 19 | 20 | 'title_prepend_title' => 'Prepend on Title', 21 | 'title_prepend_instructions' => 'This will be PREPENDED to all titles throughout the page. Not commonly used.', 22 | 23 | 'title_append_title' => 'Append on Title', 24 | 'title_append_instructions' => 'This will be APPENDED to all titles throughout the page. Commonly used for your brand/product name.', 25 | 26 | 'title_preview_title' => 'Search preview (requires reload on save)', 27 | 'title_preview_placeholder_title' => 'Demo Page Title', 28 | 'title_preview_placeholder_description' => 'This description will be prefilled by the search engine depending on your content. You can change it manually by selecting custom and typing in your own.', 29 | 30 | /** 31 | * Social Tab 32 | */ 33 | 'social_title' => 'Social', 34 | 35 | 'social_site_name_title' => 'Site Name', 36 | 'social_site_name_instructions' => 'The name of your site. This might be displayed while sharing on socials. Usually this is the name of your brand/product. This is different than the website title, which is meant for describing the content of a specififc page. Example: Site name: *Apple* Page title: *iPhone 14 Pro specifications*.', 37 | 38 | 'social_info_title' => 'General Social Settings', 39 | 'social_info_instructions' => 'The best practice for social shares is to create unique content for each page. Since this not always an option, it is advised to set this for the most shared landing pages. The information below will be used as a fallback for all other pages.', 40 | 41 | 'social_title_title' => 'Title', 42 | 'social_title_instructions' => 'Focus on accuracy, value, and clickability. Keep it short to prevent overflow. 40 characters for mobile and 60 for desktop is roughly the sweet spot. Use the raw title. Don’t include branding (e.g. your site name).', 43 | 44 | 'social_description_title' => 'Description', 45 | 'social_description_instructions' => 'General Description, can be overridden on specific pages. Complement the title to make the snippet as appealing and click-worthy as possible. Copy your meta description here if it makes sense. Keep it short and sweet. Facebook recommends 2–4 sentences, but that often truncates.', 46 | 47 | 'social_image_title' => 'Share Image', 48 | 'social_image_instructions' => 'Use your logo or any other branded image for the default image. Use images with a 1.91:1 ratio and minimum recommended dimensions of 1200x630 for optimal clarity across all devices.', 49 | 50 | 'social_image_compress_title' => 'Compress & Resize Share Image', 51 | 'social_image_compress_instructions' => 'This will compress and resize the image to 1200x630. This will compress all social share images. Do not use this if you are using a custom image resolution.', 52 | 53 | 'social_preview_title' => 'Social preview (requires reload on save)', 54 | 55 | 'social_open_graph_title' => 'Output Open Graph tags', 56 | 'social_open_graph_instructions' => 'Open Graph tags are used to control how URLs are displayed when shared on social media. If you don\'t have Open Graph tags on your site, Facebook will try to scrape the content of the page to determine what to display. This can lead to issues with how your content is displayed, and it can also lead to Facebook displaying the wrong content.', 57 | 58 | 'social_twitter_title' => 'Output Twitter tags', 59 | 'social_twitter_instructions' => 'Twitter cards are a way to attach rich photos, videos and media experience to Tweets that drive traffic to your website. You can use Twitter cards to help you drive traffic to your website, increase the visibility of your content, and get more engagement with your Tweets.', 60 | 61 | /** 62 | * Settings Tab 63 | */ 64 | 'settings_title' => 'Settings', 65 | 66 | 'settings_preview_domain_title' => 'Preview Domain/URL', 67 | 'settings_preview_domain_instructions' => 'This is the domain that will be used to generate the social and SEO previews.', 68 | 69 | 'settings_robots_title' => 'Disable page indexing (noindex, nofollow)', 70 | 'settings_robots_instructions' => 'This will prevent the page from being indexed by search engines. This is useful for pages that are not ready for public viewing, or for pages that you don\'t want to be indexed by search engines This will be set for all pages.', 71 | ]; 72 | -------------------------------------------------------------------------------- /resources/lang/en/seo.php: -------------------------------------------------------------------------------- 1 | 'SEO', 8 | 9 | 'section_meta_title' => 'Meta', 10 | 'section_meta_instructions' => 'Meta tags are essentially little content descriptors that help tell search engines what a web page is about.', 11 | 12 | 'meta_title' => 'SEOtamic Meta', 13 | 14 | 'meta_title_title' => 'Title', 15 | 'meta_title_instructions' => 'Focus on accuracy, value, and clickability. Keep it short to prevent overflow. There’s no official guidance on this, but 40 characters for mobile and 60 for desktop is roughly the sweet spot. Use the raw title. Don’t include branding (e.g., your site name).', 16 | 'meta_prepend_label' => 'Prepend to the title the text set in the SEOtamic settings', 17 | 'meta_append_label' => 'Append to the title the text set in the SEOtamic settings', 18 | 19 | 'meta_description_title' => 'Description', 20 | 'meta_description_instructions' => 'It can be used to determine the text used under the title on search engine results pages. If empty, search engines will generate this text automatically. It is best to leave this empty and let the search engine generate it automatically.', 21 | 'meta_default_description' => 'This description will be prefilled by the search engine depending on your content. You can change it manually by selecting custom and typing in your own.', 22 | 23 | 'meta_label_title' => ' Entry Title', 24 | 'meta_label_auto' => 'Auto', 25 | 'meta_label_custom' => 'Custom', 26 | 'meta_label_empty' => 'Empty', 27 | 'meta_preview_title' => 'Search preview', 28 | 29 | 'canonical_title' => 'Canonical URL', 30 | 'canonical_instructions' => 'Canonical URLs are used to tell search engines which URL is the original source of a page. This is useful when you have multiple URLs that point to the same page. For example, if you have a page that can be accessed with or without a trailing slash, you can use a canonical URL to tell search engines which one is the original. This helps search engines avoid duplicate content issues.', 31 | 32 | 'robots_title' => 'Disable page indexing (noindex, nofollow)', 33 | 'robots_instructions' => 'This will prevent this page from being indexed by search engines. This is useful for pages that are not ready for public viewing, or for pages that you don\'t want to be indexed by search engines.', 34 | ]; 35 | -------------------------------------------------------------------------------- /resources/lang/en/social.php: -------------------------------------------------------------------------------- 1 | 'Social', 9 | 10 | 'section_social_title' => 'Social', 11 | 'section_social_instructions' => 'Social (Open Graph, Twitter, …) is used to display information while sharing the website link. OpenGraph is the most common sharing protocol (used by Facebook, Slack…).', 12 | 13 | 'social_title' => 'SEOtamic Social', 14 | 15 | 16 | 'entry_social_section' => 'Social', 17 | 'entry_social_instructions' => 'Social', 18 | 19 | 'social_field_title_title' => '', 20 | 'social_field_title_instructions' => 'The title should be between 50 and 60 characters and not have any branding.', 21 | 'social_field_description_title' => 'Social description', 22 | 'social_field_description_instructions' => 'Shown below the title. It is used to describe the content of the page. If Meta description is not empty, it can be reused here. Usually the Meta description is longer.', 23 | 'social_field_label_title' => 'Auto', 24 | 'social_field_label_general' => 'General', 25 | 'social_field_label_custom' => 'Custom', 26 | 'social_field_label_meta' => 'Auto', 27 | 'social_field_preview_title' => 'Social preview', 28 | 29 | 'social_image_title' => 'Social Image', 30 | 'social_image_instructions' => 'This image will be used when sharing the page on social networks. If left empty, the default image will be used. Use images with a 1.91:1 ratio and minimum recommended dimensions of 1200x630 for optimal clarity across all devices.', 31 | ]; 32 | -------------------------------------------------------------------------------- /resources/screenshots/meta_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnj-digital/seotamic/0917b76a0564f27f40092f11e103d6d0e0032a57/resources/screenshots/meta_preview.png -------------------------------------------------------------------------------- /resources/screenshots/social_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnj-digital/seotamic/0917b76a0564f27f40092f11e103d6d0e0032a57/resources/screenshots/social_preview.png -------------------------------------------------------------------------------- /resources/views/all.antlers.html: -------------------------------------------------------------------------------- 1 | {{ partial src="seotamic::partials/general" }} 2 | {{ partial src="seotamic::partials/canonical" }} 3 | {{ partial src="seotamic::partials/og" }} 4 | {{ partial src="seotamic::partials/twitter" }} 5 | {{ partial src="seotamic::partials/robots" }} 6 | {{ partial src="seotamic::partials/related" }} -------------------------------------------------------------------------------- /resources/views/cp/settings.blade.php: -------------------------------------------------------------------------------- 1 | @extends('statamic::layout') 2 | @section('title', 'SEOtamic') 3 | 4 | @section('content') 5 |
6 |

{{ __('seotamic::general.seotamic') }}

7 |
8 |
9 |

{{ __('seotamic::general.intro') }}

10 |
11 | 12 |
13 | 16 |
17 | @stop 18 | -------------------------------------------------------------------------------- /resources/views/partials/_canonical.antlers.html: -------------------------------------------------------------------------------- 1 | {{ if meta:canonical }} 2 | 3 | {{ /if }} -------------------------------------------------------------------------------- /resources/views/partials/_general.antlers.html: -------------------------------------------------------------------------------- 1 | {{ meta:title }} 2 | 3 | {{ if meta:description }} 4 | 5 | {{ /if }} -------------------------------------------------------------------------------- /resources/views/partials/_og.antlers.html: -------------------------------------------------------------------------------- 1 | {{ if social:open_graph }} 2 | 3 | 4 | {{ if canonical }} 5 | 6 | {{ /if }} 7 | 8 | {{ if social:site_name }} 9 | 10 | {{ /if }} 11 | 12 | {{ if social:title }} 13 | 14 | {{ /if }} 15 | 16 | {{ if social:description }} 17 | 18 | {{ /if }} 19 | 20 | {{ if social:image }} 21 | 22 | {{ /if }} 23 | {{ /if }} -------------------------------------------------------------------------------- /resources/views/partials/_related.antlers.html: -------------------------------------------------------------------------------- 1 | {{ if meta:related }} 2 | {{ meta:related }} 3 | 4 | {{ /meta:related }} 5 | {{ /if }} -------------------------------------------------------------------------------- /resources/views/partials/_robots.antlers.html: -------------------------------------------------------------------------------- 1 | {{ if meta:robots }} 2 | 3 | {{ /if }} -------------------------------------------------------------------------------- /resources/views/partials/_twitter.antlers.html: -------------------------------------------------------------------------------- 1 | {{ if social:twitter }} 2 | 3 | 4 | 5 | {{ if social:title }} 6 | 7 | {{ /if }} 8 | 9 | {{ if social:description }} 10 | 11 | {{ /if }} 12 | 13 | {{ if canonical }} 14 | 15 | {{ /if }} 16 | 17 | {{ if social:image }} 18 | 19 | {{ /if }} 20 | 21 | {{ /if }} -------------------------------------------------------------------------------- /resources/views/sitemap.antlers.html: -------------------------------------------------------------------------------- 1 | {{ header }} 2 | 3 | {{ entries }} 4 | 5 | {{ loc }} 6 | {{ lastmod }} 7 | {{ alternates }} 8 | 10 | {{ /alternates }} 11 | 12 | {{ /entries }} 13 | -------------------------------------------------------------------------------- /src/Commands/MigrateCommand.php: -------------------------------------------------------------------------------- 1 | info('Migrating SEOtamic data from v2 to v3'); 36 | $this->newLine(); 37 | 38 | $this->warn('Make sure you have a backup of your data before you continue!'); 39 | 40 | if (!$this->confirm('Do you want to continue?', true)) { 41 | $this->info('Migration aborted'); 42 | return; 43 | } 44 | $this->newLine(); 45 | 46 | // 1. Go through all blueprints and check if they have the SEOtamic fields 47 | // If they do, ask the user if he wants to remove them (since they should not be there) 48 | // If they don't, add them to the skipped list 49 | $this->info('1. Migrating blueprints…'); 50 | $collections = Collection::all(); 51 | 52 | $blueprints = $collections->flatMap(function ($collection) { 53 | return $collection->entryBlueprints(); 54 | }); 55 | 56 | $blueprintsWithSeotamicFields = $blueprints->filter(function ($blueprint) { 57 | if (method_exists($blueprint, 'hasSection')) { 58 | return $blueprint->hasSection('SEO') || $blueprint->hasSection('Social'); 59 | } 60 | 61 | if (method_exists($blueprint, 'hasTab')) { 62 | return $blueprint->hasTab('SEO') || $blueprint->hasTab('Social'); 63 | } 64 | }); 65 | 66 | $skippedBlueprints = []; 67 | 68 | // Loop through all blueprintsWithSeotamicFields and ask the user if he wants to remove them 69 | $blueprintsWithSeotamicFields->each(function ($blueprint) use (&$skippedBlueprints) { 70 | $this->info('Found blueprint with SEOtamic fields: ' . $blueprint->handle()); 71 | 72 | if ($this->confirm('Do you want to remove the SEOtamic fields from this blueprint?', true)) { 73 | $this->info('Removing SEOtamic fields from blueprint: ' . $blueprint->handle()); 74 | 75 | if (method_exists($blueprint, 'removeSection')) { 76 | $blueprint->removeSection('SEO'); 77 | $blueprint->removeSection('Social'); 78 | } 79 | 80 | if (method_exists($blueprint, 'removeTab')) { 81 | $blueprint->removeTab('SEO'); 82 | $blueprint->removeTab('Social'); 83 | } 84 | 85 | $blueprint->save(); 86 | } else { 87 | $this->info('Skipping blueprint: ' . $blueprint->handle()); 88 | $skippedBlueprints[] = $blueprint->handle(); 89 | } 90 | }); 91 | 92 | $this->info('…done!'); 93 | $this->newLine(); 94 | 95 | // 2. Go through all entries (all languages) and check if they have the SEOtamic fields 96 | // If they do, convert them to the v3 format and remove the old fields 97 | // If they dont, do nothing 98 | $this->info('2. Migrating entries…'); 99 | 100 | $entries = Entry::all(); 101 | 102 | // on each entry we chech if it has any field with a seotamic_ prefix, if it has we migrate them 103 | // This also finds v3 fields, but it doesn't matter since we do not overwrite them 104 | $entriesWithSeotamicFields = $entries->filter(function ($entry) { 105 | return $entry->values()->keys()->some(function ($key) { 106 | return Str::startsWith($key, 'seotamic_'); 107 | }); 108 | }); 109 | 110 | $seotamicMeta = [ 111 | "title" => [ 112 | "append" => true, 113 | "prepend" => true, 114 | "type" => "title", 115 | "value" => "", 116 | "custom_value" => "" 117 | ], 118 | "description" => [ 119 | "value" => "", 120 | "custom_value" => "", 121 | "type" => "empty" 122 | ] 123 | ]; 124 | 125 | $seotamicSocial = [ 126 | "title" => [ 127 | "type" => "title", 128 | "value" => "", 129 | "custom_value" => "" 130 | ], 131 | "description" => [ 132 | "value" => "", 133 | "custom_value" => "", 134 | "type" => "general" 135 | ] 136 | ]; 137 | 138 | $entriesWithSeotamicFields->each(function ($entry) use ($seotamicMeta, $seotamicSocial) { 139 | if (!$entry->has('seotamic_meta')) { 140 | $seotamicMeta['title']['type'] = $entry->get('seotamic_title', "title"); 141 | 142 | if ($entry->get('seotamic_title', "title") === 'custom') { 143 | $seotamicMeta['title']['value'] = $entry->get('seotamic_custom_title'); 144 | $seotamicMeta['title']['custom_value'] = $entry->get('seotamic_custom_title'); 145 | } 146 | 147 | $seotamicMeta['title']['append'] = $entry->get('seotamic_title_append', true); 148 | $seotamicMeta['title']['prepend'] = $entry->get('seotamic_title_prepend', true); 149 | 150 | $seotamicMeta['description']['type'] = $entry->get('seotamic_meta_description', "empty"); 151 | 152 | if ($entry->get('seotamic_meta_description', "empty") === 'custom') { 153 | $seotamicMeta['description']['value'] = $entry->get('seotamic_custom_meta_description'); 154 | $seotamicMeta['description']['custom_value'] = $entry->get('seotamic_custom_meta_description'); 155 | } 156 | 157 | $seotamicSocial['title']['type'] = $entry->get('seotamic_open_graph_title', "title"); 158 | 159 | if ($entry->get('seotamic_open_graph_title', "title") === 'custom') { 160 | $seotamicSocial['title']['value'] = $entry->get('seotamic_custom_open_graph_title'); 161 | $seotamicSocial['title']['custom_value'] = $entry->get('seotamic_custom_open_graph_title'); 162 | } 163 | 164 | $seotamicSocial['description']['type'] = $entry->get('seotamic_open_graph_description', "general"); 165 | 166 | if ($entry->get('seotamic_open_graph_description', "general") === 'custom') { 167 | $seotamicSocial['description']['value'] = $entry->get('seotamic_custom_open_graph_description'); 168 | $seotamicSocial['description']['custom_value'] = $entry->get('seotamic_custom_open_graph_description'); 169 | } 170 | 171 | if ($entry->has('seotamic_image')) { 172 | $seotamicSocial['image'] = $entry->get('seotamic_image'); 173 | } 174 | 175 | if ($entry->has('seotamic_twitter_title')) { 176 | $entry->remove('seotamic_twitter_title'); 177 | } 178 | 179 | if ($entry->has('seotamic_custom_twitter_title')) { 180 | $entry->remove('seotamic_custom_twitter_title'); 181 | } 182 | 183 | if ($entry->has('seotamic_twitter_description')) { 184 | $entry->remove('seotamic_twitter_description'); 185 | } 186 | 187 | if ($entry->has('seotamic_custom_twitter_description')) { 188 | $entry->remove('seotamic_custom_twitter_description'); 189 | } 190 | 191 | $entry->set('seotamic_meta', $seotamicMeta); 192 | $entry->set('seotamic_social', $seotamicSocial); 193 | 194 | $entry->save(); 195 | } 196 | }); 197 | 198 | $this->info('…done!'); 199 | $this->newLine(); 200 | 201 | // 3. Go through all general seotamic settings (all languages) and convert/add new v3 and remove unused ones 202 | $this->info('3. Migrating general SEOtamic settings…'); 203 | 204 | $sites = Site::all(); 205 | 206 | foreach ($sites as $site) { 207 | $file->setLocale($site->locale); 208 | $globals = $file->read(false); 209 | 210 | if (array_key_exists('open_graph_site_name', $globals)) { 211 | $globals['social_site_name'] = $globals['open_graph_site_name']; 212 | unset($globals['open_graph_site_name']); 213 | } 214 | 215 | if (array_key_exists('open_graph_title', $globals)) { 216 | $globals['social_title'] = $globals['open_graph_title']; 217 | unset($globals['open_graph_title']); 218 | } 219 | 220 | if (array_key_exists('open_graph_description', $globals)) { 221 | $globals['social_description'] = $globals['open_graph_description']; 222 | unset($globals['open_graph_description']); 223 | } 224 | 225 | unset($globals['meta_description']); 226 | unset($globals['twitter_title']); 227 | unset($globals['twitter_description']); 228 | 229 | $file->write($globals); 230 | } 231 | 232 | $this->info('…done!'); 233 | $this->newLine(); 234 | 235 | // 4. Output success and write to the user which blueprints were skipped, so they can check them manually 236 | $this->info('Migration complete!'); 237 | 238 | if (count($skippedBlueprints) > 0) { 239 | $this->warn('The following blueprints were skipped, make sure to manually remove seo tabs if needed:'); 240 | $this->warn(implode(', ', $skippedBlueprints)); 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/FieldTypes/SeotamicMeta.php: -------------------------------------------------------------------------------- 1 | defaultMetaData(), $data); 19 | 20 | // if title type is title, set it as the parent title 21 | if ($data['title']['type'] === "title") { 22 | $data['title']['value'] = $this->getTitle(); 23 | } 24 | 25 | return $data; 26 | } 27 | 28 | public function preload() 29 | { 30 | return [ 31 | 'permalink' => $this->getPermalink(), 32 | 'title' => $this->getTitle(), 33 | 'seotamic' => $this->getSeotamicGlobals(), 34 | 'config' => config('seotamic'), 35 | 't' => [ 36 | 'title_title' => __('seotamic::seo.meta_title_title'), 37 | 'title_instructions' => __('seotamic::seo.meta_title_instructions'), 38 | 'prepend_label' => __('seotamic::seo.meta_prepend_label'), 39 | 'append_label' => __('seotamic::seo.meta_append_label'), 40 | 'description_title' => __('seotamic::seo.meta_description_title'), 41 | 'description_instructions' => __('seotamic::seo.meta_description_instructions'), 42 | 'default_description' => __('seotamic::seo.meta_default_description'), 43 | 'label_title' => __('seotamic::seo.meta_label_title'), 44 | 'label_custom' => __('seotamic::seo.meta_label_custom'), 45 | 'label_empty' => __('seotamic::seo.meta_label_empty'), 46 | 'preview_title' => __('seotamic::seo.meta_preview_title'), 47 | ] 48 | ]; 49 | } 50 | 51 | public function augment($value): array 52 | { 53 | // Non PRO edition, return empty array 54 | $edition = $this->addon ? $this->addon->edition() : 'lite'; 55 | if (str_starts_with(request()->path(), config('statamic.api.route')) && $edition !== 'pro') { 56 | return []; 57 | } 58 | 59 | if ($value === null) { 60 | $value = []; 61 | } 62 | 63 | // We make sure all the keys are present in the data 64 | $value = array_replace_recursive($this->defaultMetaData(), $value); 65 | 66 | $seotamic = $this->getSeotamicGlobals(); 67 | $robots_none = $seotamic['robots_none'] ? true : ($this->field->parent()->value('seotamic_robots_none') ?? false); 68 | 69 | $output = [ 70 | 'title' => $this->getTitle(), 71 | 'description' => $value['description']['value'] ?? '', 72 | 'canonical' => $this->getCanonical(), 73 | 'robots' => $robots_none ? 'nofollow,noindex' : '', 74 | 'related' => $this->getRelatedPages(), 75 | ]; 76 | 77 | if (isset($value['title']) && isset($value['title']['value'])) { 78 | if ($value['title']['type'] === 'custom') { 79 | $output['title'] = $value['title']['value']; 80 | } 81 | 82 | if ($value['title']['prepend'] && array_key_exists('title_prepend', $seotamic) && $seotamic['title_prepend']) { 83 | $output['title'] = $seotamic['title_prepend'] . ' ' . $output['title']; 84 | } 85 | 86 | if ($value['title']['append'] && array_key_exists('title_append', $seotamic) && $seotamic['title_append']) { 87 | $output['title'] .= ' ' . $seotamic['title_append']; 88 | } 89 | } 90 | 91 | return $output; 92 | } 93 | 94 | /** 95 | * Canonical URL 96 | * 97 | * By default it returns the current entry permalink. We can overide this 98 | * by selecting an entry or writing down the preferred URL in the SEO tab. 99 | * 100 | * @return string 101 | */ 102 | protected function getCanonical(): string 103 | { 104 | if ($this->field->parent() instanceof \Statamic\Entries\Collection) { 105 | return ""; 106 | } 107 | 108 | $uri = $this->field->parent()->url; 109 | $config = config('seotamic'); 110 | $base_url = env('APP_URL'); 111 | 112 | if (isset($config['headless_mode']) && $config['headless_mode'] !== false) { 113 | $base_url = $config['headless_mode']; 114 | } 115 | 116 | // remove trailing slash from base url 117 | if (substr($base_url, -1) === '/') { 118 | $base_url = substr($base_url, 0, -1); 119 | } 120 | 121 | // First child option can return 404 if there is no first child 122 | if ($this->field->parent()->value('seotamic_canonical') !== null) { 123 | $url = $this->field->parent()->value('seotamic_canonical'); 124 | 125 | // We have to make sure the given url is formatted correctly 126 | // If it's a relative path it must have a / prepended 127 | if (substr($url, 0, 4) !== 'http') { 128 | if (substr($url, 0, 1) !== '/') { 129 | $url = '/' . $url; 130 | } 131 | 132 | $url = $base_url . $url; 133 | } 134 | } else { 135 | $url = $base_url . $uri; 136 | } 137 | 138 | return $url ?? ""; 139 | } 140 | 141 | /** 142 | * Returns an array of related/alternate pages 143 | * 144 | * This pages are linked but available in different languages/options 145 | * 146 | * @return array 147 | */ 148 | protected function getRelatedPages(): ?Collection 149 | { 150 | if ($this->field->parent() instanceof \Statamic\Entries\Collection) { 151 | return null; 152 | } 153 | 154 | $sitemapEntry = SitemapController::sitemapEntry($this->field->parent()); 155 | 156 | if (!$sitemapEntry && !isset($sitemapEntry['alternates'])) { 157 | return null; 158 | } 159 | 160 | return $sitemapEntry['alternates']; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/FieldTypes/SeotamicSearchPreview.php: -------------------------------------------------------------------------------- 1 | $this->getSeotamicGlobals(), 11 | 'config' => config('seotamic'), 12 | 't' => [ 13 | 'demo_title' => __('seotamic::general.title_preview_placeholder_title'), 14 | 'default_description' => __('seotamic::general.title_preview_placeholder_description'), 15 | ] 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/FieldTypes/SeotamicSocial.php: -------------------------------------------------------------------------------- 1 | getSeotamicGlobals(); 13 | 14 | if ($data === null) { 15 | $data = []; 16 | } 17 | 18 | // We make sure all the keys are present in the data 19 | $data = array_replace_recursive($this->defaultSocialData(), $data); 20 | 21 | if ($this->field->parent() instanceof \Statamic\Entries\Entry) { 22 | $meta = $this->field->parent()->value('seotamic_meta'); 23 | } 24 | 25 | // If the parent is a collection, we use defaults/empty values 26 | // This happens if it's a new entry 27 | if ($this->field->parent() instanceof \Statamic\Entries\Collection) { 28 | $meta = $this->defaultMetaData(); 29 | } 30 | 31 | if ($meta === null) { 32 | $meta = $this->defaultMetaData(); 33 | } 34 | 35 | if ($data['title']['type'] === "title") { 36 | if ($meta['title']['type'] === "custom") { 37 | $data['title']['value'] = $meta['title']['value']; 38 | } else { 39 | $data['title']['value'] = $this->getTitle(); 40 | } 41 | } 42 | 43 | if ($data['title']['type'] === "general") { 44 | $data['title']['value'] = $globals['social_title']; 45 | } 46 | 47 | if ($data['description']['type'] === "meta") { 48 | if ($meta['description']['type'] === "custom") { 49 | $data['description']['value'] = $meta['description']['value']; 50 | } else { 51 | $data['description']['value'] = $globals['social_description']; 52 | } 53 | } 54 | 55 | if ($data['description']['type'] === "general") { 56 | $data['description']['value'] = $globals['social_description']; 57 | } 58 | 59 | return $data; 60 | } 61 | 62 | /** 63 | * Preload the Fieldtype frontend with the given extra data 64 | * 65 | * @return array 66 | */ 67 | public function preload(): array 68 | { 69 | if ($this->field->parent() instanceof \Statamic\Entries\Entry) { 70 | $meta = $this->field->parent()->value('seotamic_meta'); 71 | } 72 | 73 | // If the parent is a collection, we use defaults/empty values 74 | // This happens if it's a new entry 75 | if ($this->field->parent() instanceof \Statamic\Entries\Collection) { 76 | $meta = $this->defaultMetaData(); 77 | } 78 | 79 | return [ 80 | 'permalink' => $this->getPermalink(), 81 | 'title' => $this->getTitle(), 82 | 'meta' => $meta, 83 | 'seotamic' => $this->getSeotamicGlobals(), 84 | 'social_image' => $this->getImage(), 85 | 'config' => config('seotamic'), 86 | 't' => [ 87 | 'title_title' => __('seotamic::social.social_field_title_title'), 88 | 'title_instructions' => __('seotamic::social.social_field_title_instructions'), 89 | 'description_title' => __('seotamic::social.social_field_description_title'), 90 | 'description_instructions' => __('seotamic::social.social_field_description_instructions'), 91 | 'label_title' => __('seotamic::social.social_field_label_title'), 92 | 'label_custom' => __('seotamic::social.social_field_label_custom'), 93 | 'label_general' => __('seotamic::social.social_field_label_general'), 94 | 'label_meta' => __('seotamic::social.social_field_label_meta'), 95 | 'preview_title' => __('seotamic::social.social_field_preview_title'), 96 | ] 97 | ]; 98 | } 99 | 100 | /** 101 | * Augment the return value of the field 102 | * 103 | * @return array 104 | */ 105 | public function augment($value): array 106 | { 107 | // Non PRO edition, return empty array 108 | $edition = $this->addon ? $this->addon->edition() : 'lite'; 109 | if (str_starts_with(request()->path(), config('statamic.api.route')) && $edition !== 'pro') { 110 | return []; 111 | } 112 | 113 | if ($value === null) { 114 | $value = []; 115 | } 116 | 117 | // We make sure all the keys are present in the data 118 | $value = array_replace_recursive($this->defaultSocialData(), $value); 119 | 120 | $title = $this->getTitle(); 121 | $seotamic = $this->getSeotamicGlobals(); 122 | $meta = $this->field->parent()->data()->get('seotamic_meta'); 123 | $compress = array_key_exists('social_image_compress', $seotamic) ? $seotamic['social_image_compress'] : true; 124 | $social_image = $this->getImage($compress); 125 | 126 | $output = [ 127 | 'open_graph' => array_key_exists('open_graph_display', $seotamic) ? $seotamic['open_graph_display'] : "", 128 | 'twitter' => array_key_exists('twitter_display', $seotamic) ? $seotamic['twitter_display'] : "", 129 | 'site_name' => array_key_exists('social_site_name', $seotamic) ? $seotamic['social_site_name'] : "", 130 | 'title' => $title, 131 | 'description' => array_key_exists('social_description', $seotamic) ? $seotamic['social_description'] : "", 132 | 'image' => $social_image 133 | ]; 134 | 135 | if (isset($value['title']) && isset($value['title']['value'])) { 136 | if ($value['title']['type'] === 'title' && isset($meta['title']['value']) && $meta['title']['type'] === 'custom') { 137 | $output['title'] = $meta['title']['value']; 138 | } 139 | 140 | if ($value['title']['type'] === 'custom') { 141 | $output['title'] = $value['title']['value']; 142 | } 143 | 144 | if ($value['title']['type'] === 'general') { 145 | $output['title'] = $seotamic['social_title']; 146 | } 147 | } 148 | 149 | if (isset($value['description']) && isset($value['description']['value'])) { 150 | if ($value['description']['type'] === 'meta' && isset($meta['description']['type']) && $meta['description']['type'] === 'custom') { 151 | $output['description'] = $meta['description']['value']; 152 | } 153 | 154 | if ($value['description']['type'] === 'custom') { 155 | $output['description'] = $value['description']['value']; 156 | } 157 | } 158 | 159 | return $output; 160 | } 161 | 162 | 163 | /** 164 | * Get the image absolute url for the social media 165 | * 166 | * @return string 167 | */ 168 | protected function getImage($compress = false): string 169 | { 170 | if ($this->field->parent() instanceof \Statamic\Entries\Entry) { 171 | $blueprint = $this->field->parent()->blueprint(); 172 | 173 | // check if image field is overriden in the config 174 | $social_image_override = config('seotamic.social_image_override', []); 175 | $image_field = array_key_exists($blueprint->handle, $social_image_override) ? $social_image_override[$blueprint->handle] : false; 176 | 177 | $social_image = $this->field->parent()->value('seotamic_image') ?? $this->field->parent()->data()->get($image_field); 178 | } else { 179 | // New Entry 180 | $social_image = ''; 181 | } 182 | 183 | // Use the default seotamic image from globals if entry doesn't have one 184 | if (!$social_image) { 185 | $seotamic = $this->getSeotamicGlobals(); 186 | $social_image = array_key_exists('social_image', $seotamic) ? $seotamic['social_image'] : ''; 187 | } 188 | 189 | $asset = Asset::find(config('seotamic.container') . '::' . $social_image); 190 | 191 | if (!$asset || !$asset->isImage()) { 192 | return ""; 193 | } 194 | 195 | if ($compress) { 196 | return url(Image::manipulate($asset, ['w' => 1200, 'h' => 630, 'q' => '70', 'fit' => 'crop'])); 197 | } 198 | 199 | return $asset->absoluteUrl(); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/FieldTypes/SeotamicSocialPreview.php: -------------------------------------------------------------------------------- 1 | getSeotamicGlobals(); 13 | 14 | return [ 15 | 'seotamic' => $globals, 16 | 'image' => $this->getImageUrl($globals['social_image']), 17 | 'config' => config('seotamic'), 18 | 't' => [ 19 | 'demo_title' => __('seotamic::general.meta_field_demo_title'), 20 | 'default_description' => __('seotamic::general.meta_field_default_description'), 21 | ] 22 | ]; 23 | } 24 | 25 | protected function getImageUrl($image): string 26 | { 27 | $asset = Asset::find(config('seotamic.container') . '::' . $image); 28 | 29 | if (!$asset || !$asset->isImage()) { 30 | return ""; 31 | } 32 | 33 | return $asset->url(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/FieldTypes/SeotamicType.php: -------------------------------------------------------------------------------- 1 | file = $file; 22 | $this->addon = AddonFacade::get('cnj/seotamic'); 23 | } 24 | 25 | /** 26 | * Set the Type config field values, to be set from the CP 27 | * 28 | * @return array 29 | */ 30 | protected function configFieldItems(): array 31 | { 32 | return []; 33 | } 34 | 35 | /** 36 | * Get the title of the Entry 37 | * 38 | * @return string 39 | */ 40 | protected function getTitle(): string 41 | { 42 | if ($this->field->parent() instanceof \Statamic\Entries\Collection) { 43 | return ""; 44 | } 45 | 46 | // If the collection has a computed property, the above check fails 47 | if ($this->field->parent() instanceof \Statamic\Entries\Entry && $this->field->parent()->value('title') === null) { 48 | return ""; 49 | } 50 | 51 | return $this->field->parent()->value('title'); 52 | } 53 | 54 | /** 55 | * Get the permalink of the Entry 56 | * 57 | * @return string 58 | */ 59 | protected function getPermalink(): string 60 | { 61 | if ($this->field->parent() instanceof \Statamic\Entries\Collection) { 62 | return ""; 63 | } 64 | 65 | // If the collection has a computed property, the above check fails 66 | if ($this->field->parent() instanceof \Statamic\Entries\Entry && $this->field->parent()->value('title') === null) { 67 | return ""; 68 | } 69 | 70 | return URL::to("/") . Entry::find($this->field->parent()->id)->url(); 71 | } 72 | 73 | /** 74 | * Fetch the Seotamic global config from the Yaml file 75 | * 76 | * @return array 77 | */ 78 | protected function getSeotamicGlobals(): array 79 | { 80 | $config = $this->file->read(false); 81 | 82 | // We make sure all the keys are present in the data 83 | $config = array_replace_recursive($this->defaultGlobalData(), $config); 84 | 85 | return $config; 86 | } 87 | 88 | /** 89 | * Default data for the Social fieldtype 90 | * 91 | * @return array 92 | */ 93 | protected function defaultSocialData(): array 94 | { 95 | return [ 96 | "title" => [ 97 | "type" => "title", 98 | "value" => "", 99 | "custom_value" => "" 100 | ], 101 | "description" => [ 102 | "value" => "", 103 | "custom_value" => "", 104 | "type" => "meta" 105 | ] 106 | ]; 107 | } 108 | 109 | /** 110 | * Default data for the Meta fieldtype 111 | * 112 | * @return array 113 | */ 114 | protected function defaultMetaData(): array 115 | { 116 | return [ 117 | "title" => [ 118 | "append" => true, 119 | "prepend" => true, 120 | "type" => "title", 121 | "value" => "", 122 | "custom_value" => "" 123 | ], 124 | "description" => [ 125 | "value" => "", 126 | "custom_value" => "", 127 | "type" => "empty" 128 | ] 129 | ]; 130 | } 131 | 132 | /** 133 | * Default data for the global config 134 | * 135 | * @return array 136 | */ 137 | protected function defaultGlobalData(): array 138 | { 139 | return [ 140 | "title_prepend" => "", 141 | "title_append" => "", 142 | "social_title" => "", 143 | "social_description" => "", 144 | "social_image" => "", 145 | "social_image_compress" => true, 146 | "open_graph_display" => true, 147 | "twitter_display" => true, 148 | "preview_domain" => "", 149 | "robots_none" => false, 150 | "social_image_override" => [] 151 | ]; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/File/File.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 44 | $this->yaml = $yaml; 45 | $this->configFile = $config->get('seotamic.file'); 46 | 47 | // Set default locale value 48 | $this->locale = $sites->current()->locale(); 49 | } 50 | 51 | /** 52 | * Reads the YAML settings file and returns an array of settings 53 | * 54 | * Read is done from the cache, if the appropriate key exists and $fromCache 55 | * is set to true (default). 56 | * 57 | * @param bool fromCache 58 | * @return array 59 | * @throws ParseException 60 | */ 61 | public function read($fromCache = true) 62 | { 63 | if ($fromCache && Cache::has($this->cacheKey())) { 64 | return Cache::get($this->cacheKey()); 65 | } 66 | 67 | $values = $this->yaml->parse($this->manager->disk()->get($this->file())); 68 | Cache::forever($this->cacheKey(), $values); 69 | 70 | return $values; 71 | } 72 | 73 | /** 74 | * Writes the given array to the Yaml settings file and clears the cache for this key 75 | * 76 | * @param array $values 77 | * @return void 78 | */ 79 | public function write($values) 80 | { 81 | Cache::forget($this->cachekey()); 82 | 83 | $this->manager->disk()->put($this->file(), $this->yaml->dump($values)); 84 | } 85 | 86 | /** 87 | * Set the locale value 88 | * 89 | * @param string $value 90 | * @return void 91 | */ 92 | public function setLocale($value) 93 | { 94 | $this->locale = $value; 95 | } 96 | 97 | /** 98 | * Returns the file name with the locale appended 99 | * 100 | * @return string 101 | */ 102 | private function file() 103 | { 104 | return base_path("content/{$this->configFile}_{$this->locale}.yaml"); 105 | } 106 | 107 | /** 108 | * Returns the cache key with the locale appended 109 | * 110 | * @return string 111 | */ 112 | private function cacheKey() 113 | { 114 | return "seotamic_{$this->locale}"; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/GraphQL/SeotamicMetaField.php: -------------------------------------------------------------------------------- 1 | 'Seotamic meta information', 14 | ]; 15 | 16 | public function type(): Type 17 | { 18 | return GraphQL::type(SeotamicMetaType::NAME); 19 | } 20 | 21 | protected function resolve(Entry $entry) 22 | { 23 | $ignoreBlueprints = config('seotamic.ignore_blueprints', []); 24 | 25 | if (in_array($entry->blueprint()->handle(), $ignoreBlueprints)) { 26 | return null; 27 | } 28 | 29 | return $entry->seotamic_meta; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/GraphQL/SeotamicMetaType.php: -------------------------------------------------------------------------------- 1 | self::NAME, 15 | ]; 16 | 17 | public function fields(): array 18 | { 19 | return [ 20 | 'title' => [ 21 | 'type' => GraphQL::string(), 22 | 'description' => 'Entry meta title', 23 | 'resolve' => $this->resolver() 24 | ], 25 | 'description' => [ 26 | 'type' => GraphQL::string(), 27 | 'description' => 'Entry meta description', 28 | 'resolve' => $this->resolver() 29 | ], 30 | 'canonical' => [ 31 | 'type' => GraphQL::string(), 32 | 'description' => 'Canonical page URL', 33 | 'resolve' => $this->resolver() 34 | ], 35 | 'robots' => [ 36 | 'type' => GraphQL::string(), 37 | 'description' => 'Robots meta tag value', 38 | 'resolve' => $this->resolver() 39 | ], 40 | 'related' => [ 41 | 'type' => GraphQL::string(), 42 | 'description' => 'Related pages', 43 | 'resolve' => $this->resolver() 44 | ], 45 | ]; 46 | } 47 | 48 | private function resolver() 49 | { 50 | return function (array $values, $args, $context, ResolveInfo $info) { 51 | return $values[$info->fieldName]; 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/GraphQL/SeotamicSocialField.php: -------------------------------------------------------------------------------- 1 | 'Seotamic social information', 14 | ]; 15 | 16 | public function type(): Type 17 | { 18 | return GraphQL::type(SeotamicSocialType::NAME); 19 | } 20 | 21 | protected function resolve(Entry $entry) 22 | { 23 | $ignoreBlueprints = config('seotamic.ignore_blueprints', []); 24 | 25 | if (in_array($entry->blueprint()->handle(), $ignoreBlueprints)) { 26 | return null; 27 | } 28 | 29 | return $entry->seotamic_social; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/GraphQL/SeotamicSocialType.php: -------------------------------------------------------------------------------- 1 | self::NAME, 15 | ]; 16 | 17 | public function fields(): array 18 | { 19 | return [ 20 | 'open_graph' => [ 21 | 'type' => GraphQL::boolean(), 22 | 'description' => 'Output Open Graph tags', 23 | 'resolve' => $this->resolver() 24 | ], 25 | 'twitter' => [ 26 | 'type' => GraphQL::boolean(), 27 | 'description' => 'Output Twitter tags', 28 | 'resolve' => $this->resolver() 29 | ], 30 | 'site_name' => [ 31 | 'type' => GraphQL::string(), 32 | 'description' => 'Social site name', 33 | 'resolve' => $this->resolver() 34 | ], 35 | 'title' => [ 36 | 'type' => GraphQL::string(), 37 | 'description' => 'Social title', 38 | 'resolve' => $this->resolver() 39 | ], 40 | 'description' => [ 41 | 'type' => GraphQL::string(), 42 | 'description' => 'Social description', 43 | 'resolve' => $this->resolver() 44 | ], 45 | 'image' => [ 46 | 'type' => GraphQL::string(), 47 | 'description' => 'Social image', 48 | 'resolve' => $this->resolver() 49 | ], 50 | ]; 51 | } 52 | 53 | private function resolver() 54 | { 55 | return function (array $values, $args, $context, ResolveInfo $info) { 56 | return $values[$info->fieldName]; 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Http/Controllers/SettingsController.php: -------------------------------------------------------------------------------- 1 | file = $file; 21 | 22 | parent::__construct($request); 23 | } 24 | 25 | public function index(Request $request) 26 | { 27 | $this->setLocale(); 28 | 29 | $blueprint = $this->formBlueprint(); 30 | $fields = $blueprint->fields(); 31 | 32 | $values = $this->file->read(false); 33 | 34 | $fields = $fields->addValues($values); 35 | 36 | $fields = $fields->preProcess(); 37 | 38 | return view('seotamic::cp.settings', [ 39 | 'blueprint' => $blueprint->toPublishArray(), 40 | 'values' => $fields->values(), 41 | 'meta' => $fields->meta(), 42 | ]); 43 | } 44 | 45 | public function update(Request $request) 46 | { 47 | $this->setLocale(); 48 | 49 | $blueprint = $this->formBlueprint(); 50 | $fields = $blueprint->fields()->addValues($request->all()); 51 | 52 | // Perform validation. Like Laravel's standard validation, if it fails, 53 | // a 422 response will be sent back with all the validation errors. 54 | $fields->validate(); 55 | 56 | // Perform post-processing. This will convert values the Vue components 57 | // were using into values suitable for putting into storage. 58 | $values = $fields->process()->values(); 59 | 60 | $this->file->write($values->toArray()); 61 | } 62 | 63 | /** 64 | * Since we are accessing the files via CP, we need to fetch the 65 | * current language via a session variable, and set the locale 66 | * 67 | * @return void 68 | */ 69 | private function setLocale() 70 | { 71 | $this->file->setLocale( 72 | session('statamic.cp.selected-site') ? 73 | Site::get(session('statamic.cp.selected-site'))->locale() : 74 | Site::current()->locale() 75 | ); 76 | } 77 | 78 | protected function formBlueprint() 79 | { 80 | return Blueprint::makeFromTabs([ 81 | 'name' => [ 82 | 'display' => __('seotamic::general.title_title'), 83 | 'fields' => [ 84 | 'section_title' => [ 85 | 'type' => 'section', 86 | 'display' => __('seotamic::general.title_section_title'), 87 | 'instructions' => __('seotamic::general.title_section_instructions') 88 | ], 89 | 'title_prepend' => [ 90 | 'type' => 'text', 91 | 'character_limit' => '25', 92 | 'display' => __('seotamic::general.title_prepend_title'), 93 | 'instructions' => __('seotamic::general.title_prepend_instructions'), 94 | ], 95 | 'title_append' => [ 96 | 'type' => 'text', 97 | 'character_limit' => '25', 98 | 'display' => __('seotamic::general.title_append_title'), 99 | 'instructions' => __('seotamic::general.title_append_instructions'), 100 | ], 101 | 'search_preview' => [ 102 | 'type' => 'seotamic_search_preview', 103 | 'display' => __('seotamic::general.title_preview_title'), 104 | ], 105 | ], 106 | ], 107 | 'social' => [ 108 | 'display' => __('seotamic::general.social_title'), 109 | 'fields' => [ 110 | 'social_site_name' => [ 111 | 'type' => 'text', 112 | 'character_limit' => '50', 113 | 'display' => __('seotamic::general.social_site_name_title'), 114 | 'instructions' => __('seotamic::general.social_site_name_instructions'), 115 | ], 116 | 'section_social' => [ 117 | 'type' => 'section', 118 | 'display' => __('seotamic::general.social_info_title'), 119 | 'instructions' => __('seotamic::general.social_info_instructions') 120 | ], 121 | 'social_title' => [ 122 | 'type' => 'text', 123 | 'character_limit' => '60', 124 | 'display' => __('seotamic::general.social_title_title'), 125 | 'instructions' => __('seotamic::general.social_title_instructions'), 126 | ], 127 | 'social_description' => [ 128 | 'type' => 'textarea', 129 | 'character_limit' => '60', 130 | 'display' => __('seotamic::general.social_description_title'), 131 | 'instructions' => __('seotamic::general.social_description_instructions'), 132 | ], 133 | 'social_image' => [ 134 | 'type' => 'assets', 135 | 'container' => config('seotamic.container'), 136 | 'max_files' => 1, 137 | 'display' => __('seotamic::general.social_image_title'), 138 | 'instructions' => __('seotamic::general.social_image_instructions'), 139 | ], 140 | 'social_image_compress' => [ 141 | 'type' => 'toggle', 142 | 'display' => __('seotamic::general.social_image_compress_title'), 143 | 'instructions' => __('seotamic::general.social_image_compress_instructions'), 144 | 'default' => true, 145 | ], 146 | 'social_preview' => [ 147 | 'type' => 'seotamic_social_preview', 148 | 'display' => __('seotamic::general.social_preview_title'), 149 | ], 150 | 'open_graph_display' => [ 151 | 'type' => 'toggle', 152 | 'display' => __('seotamic::general.social_open_graph_title'), 153 | 'instructions' => __('seotamic::general.social_open_graph_instructions'), 154 | 'default' => true, 155 | ], 156 | 'twitter_display' => [ 157 | 'type' => 'toggle', 158 | 'display' => __('seotamic::general.social_twitter_title'), 159 | 'instructions' => __('seotamic::general.social_twitter_instructions'), 160 | 'default' => true, 161 | ], 162 | ] 163 | ], 164 | 'Settings' => [ 165 | 'display' => __('seotamic::general.settings_title'), 166 | 'fields' => [ 167 | 'preview_domain' => [ 168 | 'type' => 'text', 169 | 'character_limit' => '50', 170 | 'prepend' => 'https://', 171 | 'display' => __('seotamic::general.settings_preview_domain_title'), 172 | 'instructions' => __('seotamic::general.settings_preview_domain_instructions'), 173 | ], 174 | 'robots_none' => [ 175 | 'type' => 'toggle', 176 | 'display' => __('seotamic::general.settings_robots_title'), 177 | 'instructions' => __('seotamic::general.settings_robots_instructions'), 178 | 'default' => false, 179 | ], 180 | ] 181 | ], 182 | ]); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Http/Controllers/SitemapController.php: -------------------------------------------------------------------------------- 1 | edition() === 'pro', 404); 17 | 18 | $content = Cache::rememberForever('seotamic_sitemap', function () { 19 | $entries = Collection::all() 20 | ->flatMap(function ($collection) { 21 | return $collection->cascade('seo') !== false && $collection->cascade('social') !== false 22 | ? $collection->queryEntries()->get() 23 | : collect(); 24 | }) 25 | ->filter(function (Entry $entry) { 26 | return self::shouldBeIndexed($entry); 27 | }) 28 | ->map(function (Entry $entry) { 29 | return self::sitemapEntry($entry); 30 | }); 31 | 32 | return view('seotamic::sitemap', [ 33 | 'header' => '', 34 | 'entries' => $entries, 35 | ])->render(); 36 | }); 37 | 38 | return response($content)->header('Content-Type', 'text/xml'); 39 | } 40 | 41 | private static function shouldBeIndexed(Entry $entry) 42 | { 43 | return Cache::rememberForever( 44 | 'seotamic_sitemap_should_be_indexed' . $entry->id(), 45 | function () use ($entry) { 46 | 47 | if(!empty($entry->protect)){ 48 | return false; 49 | } 50 | 51 | if (is_null($entry->uri())) { 52 | return false; 53 | } 54 | 55 | if ($entry->status() !== 'published') { 56 | return false; 57 | } 58 | 59 | if (!empty($entry->seotamic_meta['robots'])) { 60 | return false; 61 | } 62 | 63 | return true; 64 | } 65 | ); 66 | } 67 | 68 | public static function sitemapEntry(Entry $entry) 69 | { 70 | return Cache::rememberForever( 71 | 'seotamic_sitemap_entry' . $entry->id(), 72 | function () use ($entry) { 73 | return [ 74 | 'loc' => self::entryAbsoluteUrl($entry), 75 | 'lastmod' => $entry->lastModified()->toAtomString(), 76 | 'alternates' => $entry->sites() 77 | ->map(fn ($site) => $entry->in($site)) 78 | ->filter(function (?Entry $page) use ($entry) { 79 | return $page && self::shouldBeIndexed($page) && $page->id() !== $entry->id(); 80 | }) 81 | ->map( 82 | fn (Entry $entry) => [ 83 | 'lang' => $entry->site()->locale, 84 | 'href' => self::entryAbsoluteUrl($entry) 85 | ] 86 | )->values() 87 | ]; 88 | } 89 | ); 90 | } 91 | 92 | private static function entryAbsoluteUrl(Entry $entry) 93 | { 94 | // if seotamic headless mode is set, use that domain, otherwise use the entry absoluteUrl 95 | if (config('seotamic.headless_mode')) { 96 | return config('seotamic.headless_mode') . $entry->url(); 97 | } 98 | 99 | return $entry->absoluteUrl(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | __DIR__ . '/routes/cp.php', 25 | 'web' => __DIR__ . '/routes/web.php', 26 | ]; 27 | 28 | protected $fieldtypes = [ 29 | FieldTypes\SeotamicMeta::class, 30 | FieldTypes\SeotamicSocial::class, 31 | FieldTypes\SeotamicSearchPreview::class, 32 | FieldTypes\SeotamicSocialPreview::class, 33 | ]; 34 | 35 | protected $tags = [ 36 | \Cnj\Seotamic\Tags\SeotamicTags::class, 37 | ]; 38 | 39 | protected $vite = [ 40 | 'input' => [ 41 | 'resources/js/cp.js', 42 | 'resources/css/cp.css', 43 | ], 44 | 'publicDirectory' => 'resources/dist', 45 | 'hotFile' => __DIR__ . '/../resources/dist/hot', 46 | ]; 47 | 48 | protected $scripts = [ 49 | __DIR__ . '/../resources/dist/js/cp.js', 50 | ]; 51 | 52 | protected $stylesheets = [ 53 | __DIR__ . '/../resources/dist/css/cp.css', 54 | ]; 55 | 56 | public function bootAddon() 57 | { 58 | $this->loadViewsFrom(__DIR__ . '/../resources/views/', 'seotamic'); 59 | $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'seotamic'); 60 | 61 | Nav::extend(function ($nav) { 62 | $nav->content('SEOtamic') 63 | ->section('Tools') 64 | ->route('cnj.seotamic.index') 65 | ->can('view seotamic tool') 66 | ->icon('seo-search-graph'); 67 | }); 68 | 69 | $this->publishes([ 70 | __DIR__ . '/../config/seotamic.php' => config_path('seotamic.php') 71 | ], 'config'); 72 | 73 | Permission::register('view seotamic tool') 74 | ->label('View global SEOtamic settings'); 75 | 76 | $addon = $this->getAddon(); 77 | $edition = $addon ? $addon->edition() : 'lite'; 78 | 79 | // GraphQL support for Pro edition 80 | if (config('statamic.graphql.enabled') && $edition === 'pro') { 81 | GraphQL::addType(SeotamicMetaType::class); 82 | GraphQL::addType(SeotamicSocialType::class); 83 | 84 | GraphQL::addField(EntryInterface::NAME, 'seotamic_meta', fn() => (new SeotamicMetaField())->toArray()); 85 | GraphQL::addField(EntryInterface::NAME, 'seotamic_social', fn() => (new SeotamicSocialField())->toArray()); 86 | } 87 | } 88 | 89 | public function register() 90 | { 91 | $this->mergeConfigFrom(__DIR__ . '/../config/seotamic.php', 'seotamic'); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/SitemapSubscriber.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public function subscribe(Dispatcher $events): array 19 | { 20 | return [ 21 | EntrySaved::class => 'flushSitemapCache', 22 | EntryCreated::class => 'flushSitemapCache', 23 | EntryDeleted::class => 'flushSitemapCache', 24 | ]; 25 | } 26 | 27 | /** 28 | * Flush the sitemap cache 29 | * 30 | */ 31 | public function flushSitemapCache($event) 32 | { 33 | Cache::forget('seotamic_sitemap_should_be_indexed' . $event->entry->id()); 34 | Cache::forget('seotamic_sitemap_sitemap_entry', $event->entry->id()); 35 | Cache::forget('seotamic_sitemap'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Subscriber.php: -------------------------------------------------------------------------------- 1 | 'addFields', 19 | ]; 20 | 21 | /** 22 | * Registers event listeners 23 | * 24 | * @param \Illuminate\Events\Dispatcher $events 25 | */ 26 | public function subscribe($events) 27 | { 28 | foreach ($this->events as $event => $method) { 29 | $events->listen($event, self::class . '@' . $method); 30 | } 31 | } 32 | 33 | /** 34 | * Add SEOtamic fields to the collection blueprint 35 | * 36 | * @param mixed $event 37 | */ 38 | public function addFields($event) 39 | { 40 | $this->blueprint = $event->blueprint; 41 | 42 | $ignoreBlueprints = config('seotamic.ignore_blueprints', []); 43 | 44 | // Do not add fields to blueprints that are in the ignore list 45 | if (in_array($this->blueprint->handle(), $ignoreBlueprints)) { 46 | return; 47 | } 48 | 49 | // Do not add fields to the blueprint editor (this avoids adding the fields on blueprint changes) 50 | if (Str::containsAll(request()->path(), [config('statamic.cp.route', 'cp'), 'blueprints'])) { 51 | return; 52 | } 53 | 54 | // This should not be translated, so we can target them 55 | $this->blueprint->ensureFieldsInTab($this->getMetaFields(), 'SEO'); 56 | $this->blueprint->ensureFieldsInTab($this->getSocialFields(), 'Social'); 57 | } 58 | 59 | /** 60 | * Array of SEOtamic Meta fields 61 | * 62 | * @return array 63 | */ 64 | private function getMetaFields() 65 | { 66 | return [ 67 | 'seotamic_meta_section' => [ 68 | 'type' => 'section', 69 | 'localizable' => false, 70 | 'listable' => 'hidden', 71 | 'display' => __('seotamic::seo.section_meta_title'), 72 | 'instructions' => __('seotamic::seo.section_meta_instructions'), 73 | ], 74 | 'seotamic_meta' => [ 75 | 'type' => 'seotamic_meta', 76 | 'localizable' => true, 77 | 'listable' => 'hidden', 78 | 'display' => __('seotamic::seo.meta_title'), 79 | ], 80 | 'seotamic_canonical' => [ 81 | 'display' => __('seotamic::seo.canonical_title'), 82 | 'instructions' => __('seotamic::seo.canonical_instructions'), 83 | 'localizable' => true, 84 | 'listable' => 'hidden', 85 | 'input_type' => 'text', 86 | 'type' => 'text', 87 | ], 88 | 'seotamic_robots_none' => [ 89 | 'type' => 'toggle', 90 | 'localizable' => true, 91 | 'display' => __('seotamic::seo.robots_title'), 92 | 'instructions' => __('seotamic::seo.robots_instructions'), 93 | 'default' => false, 94 | ] 95 | ]; 96 | } 97 | 98 | /** 99 | * Array of SEOtamic Meta fields 100 | * 101 | * @return array 102 | */ 103 | private function getSocialFields() 104 | { 105 | return [ 106 | 'seotamic_social_section' => [ 107 | 'type' => 'section', 108 | 'localizable' => true, 109 | 'listable' => 'hidden', 110 | 'display' => __('seotamic::social.section_social_title'), 111 | 'instructions' => __('seotamic::social.section_social_instructions'), 112 | ], 113 | 114 | 'seotamic_social' => [ 115 | 'type' => 'seotamic_social', 116 | 'localizable' => true, 117 | 'listable' => 'hidden', 118 | 'display' => __('seotamic::social.social_title'), 119 | ], 120 | 'seotamic_image' => [ 121 | 'container' => config('seotamic.container'), 122 | 'mode' => 'grid', 123 | 'restrict' => false, 124 | 'allow_uploads' => true, 125 | 'max_files' => 1, 126 | 'type' => 'assets', 127 | 'localizable' => true, 128 | 'listable' => 'hidden', 129 | 'display' => __('seotamic::social.social_image_title'), 130 | 'instructions' => __('seotamic::social.social_image_instructions'), 131 | ] 132 | ]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Tags/SeotamicTags.php: -------------------------------------------------------------------------------- 1 | values()); 23 | } 24 | 25 | /** 26 | * Returns only the requested tag as a string. 27 | * 28 | * This is useful when you want to use the tag in a template. 29 | * If the tag does not exist, it returns null. 30 | * 31 | * @return string | null 32 | */ 33 | public function wildcard() 34 | { 35 | return Arr::get($this->values(), str_replace(":", ".", $this->method)); 36 | } 37 | 38 | /** 39 | * Outputs only the Open Graph Tags 40 | */ 41 | public function og() 42 | { 43 | return view('seotamic::partials._og', $this->values()); 44 | } 45 | 46 | /** 47 | * Outputs only the Twitter tags 48 | */ 49 | public function twitter() 50 | { 51 | return view('seotamic::partials._twitter', $this->values()); 52 | } 53 | 54 | protected function values(): array 55 | { 56 | return Blink::once('seotamic::values', function () { 57 | return [ 58 | 'meta' => $this->context->value('seotamic_meta'), 59 | 'social' => $this->context->value('seotamic_social') 60 | ]; 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Tags/Tags.php: -------------------------------------------------------------------------------- 1 | values = $file->read(); 32 | 33 | $this->config = $config; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/cp.php: -------------------------------------------------------------------------------- 1 | group(function () { 6 | Route::get('/cnj/seotamic/', [SettingsController::class, 'index'])->name('cnj.seotamic.index'); 7 | Route::post('/cnj/seotamic/', [SettingsController::class, 'update'])->name('cnj.seotamic.update'); 8 | }); 9 | -------------------------------------------------------------------------------- /src/routes/web.php: -------------------------------------------------------------------------------- 1 | name('cnj.seotamic.sitemap'); 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prefix: "seotamic-", 3 | 4 | content: [ 5 | "./resources/views/**/*.html", 6 | "./resources/views/**/*.php", 7 | "./resources/js/**/*.js", 8 | "./resources/js/**/*.vue", 9 | ], 10 | 11 | corePlugins: { 12 | preflight: false, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /tests/BaseTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/MetaTest.php: -------------------------------------------------------------------------------- 1 | assertArrayHasKey('title', $entry->seotamic_meta); 14 | $this->assertArrayHasKey('description', $entry->seotamic_meta); 15 | $this->assertArrayHasKey('canonical', $entry->seotamic_meta); 16 | $this->assertArrayHasKey('robots', $entry->seotamic_meta); 17 | $this->assertArrayHasKey('related', $entry->seotamic_meta); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/SitemapTest.php: -------------------------------------------------------------------------------- 1 | editions(['lite', 'pro']); 15 | Config::set('statamic.editions.addons', [ 16 | 'cnj/seotamic' => 'pro' 17 | ]); 18 | 19 | } 20 | 21 | public function test_it_returns_404_when_sitemap_is_disabled(): void 22 | { 23 | Config::set('seotamic.sitemap', false); 24 | 25 | $this 26 | ->get('/sitemap.xml') 27 | ->assertStatus(404); 28 | } 29 | 30 | public function test_it_returns_200_when_sitemap_is_enabled(): void 31 | { 32 | Config::set('seotamic.sitemap', true); 33 | 34 | $this 35 | ->get('/sitemap.xml') 36 | ->assertStatus(200); 37 | } 38 | 39 | public function test_it_returns_valid_xml(): void 40 | { 41 | $response = $this->get("/sitemap.xml"); 42 | $response->assertOk(200); 43 | 44 | $xml = simplexml_load_string($response->getContent()); 45 | $this->assertNotFalse($xml); 46 | } 47 | 48 | public function test_it_returns_home(): void 49 | { 50 | $this 51 | ->get('/sitemap.xml') 52 | ->assertOk() 53 | ->assertSee('/home'); 54 | } 55 | 56 | public function test_it_excludes_protected_collections(): void 57 | { 58 | $this 59 | ->get('/sitemap.xml') 60 | ->assertOk(200) 61 | ->assertDontSee('/protected_collection/test'); 62 | } 63 | 64 | public function test_it_excludes_protected_entities(): void 65 | { 66 | $this 67 | ->get('/sitemap.xml') 68 | ->assertOk(200) 69 | ->assertDontSee('/protected_entity'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/SocialTest.php: -------------------------------------------------------------------------------- 1 | assertArrayHasKey('open_graph', $entry->seotamic_social); 14 | $this->assertArrayHasKey('twitter', $entry->seotamic_social); 15 | $this->assertArrayHasKey('site_name', $entry->seotamic_social); 16 | $this->assertArrayHasKey('title', $entry->seotamic_social); 17 | $this->assertArrayHasKey('description', $entry->seotamic_social); 18 | $this->assertArrayHasKey('image', $entry->seotamic_social); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | parse()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnj-digital/seotamic/0917b76a0564f27f40092f11e103d6d0e0032a57/tests/__fixtures__/content/assets/.gitkeep -------------------------------------------------------------------------------- /tests/__fixtures__/content/assets/assets.yaml: -------------------------------------------------------------------------------- 1 | title: Assets 2 | disk: assets -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnj-digital/seotamic/0917b76a0564f27f40092f11e103d6d0e0032a57/tests/__fixtures__/content/collections/.gitkeep -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages.yaml: -------------------------------------------------------------------------------- 1 | title: Pages 2 | sites: 3 | - en 4 | - it 5 | - sl 6 | propagate: false 7 | template: default 8 | layout: layout 9 | revisions: false 10 | route: '{parent_uri}/{slug}' 11 | sort_dir: asc 12 | preview_targets: 13 | - 14 | label: Entry 15 | url: '{permalink}' 16 | refresh: true 17 | structure: 18 | root: true 19 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/en/contact.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 7623f560-baef-45eb-9ece-818c6f30eaec 3 | blueprint: page 4 | title: Contact 5 | author: 45a5e4c2-35eb-437e-82f2-36e31de8d660 6 | seotamic_meta: 7 | title: 8 | append: true 9 | prepend: true 10 | type: title 11 | value: Contact 12 | custom_value: null 13 | description: 14 | value: null 15 | custom_value: null 16 | type: empty 17 | seotamic_robots_none: false 18 | seotamic_social: 19 | title: 20 | type: title 21 | value: Contact 22 | custom_value: null 23 | description: 24 | value: null 25 | custom_value: null 26 | type: meta 27 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 28 | updated_at: 1716287616 29 | template: default 30 | parent: home 31 | --- 32 | 33 | # Contact Us 34 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/en/gallery.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 04ed7a1e-f538-49c0-85ab-c42669d11cd2 3 | blueprint: page 4 | title: Gallery 5 | author: 45a5e4c2-35eb-437e-82f2-36e31de8d660 6 | template: default 7 | seotamic_meta: 8 | title: 9 | append: true 10 | prepend: true 11 | type: title 12 | value: null 13 | custom_value: null 14 | description: 15 | value: null 16 | custom_value: null 17 | type: empty 18 | seotamic_robots_none: false 19 | seotamic_social: 20 | title: 21 | type: title 22 | value: null 23 | custom_value: null 24 | description: 25 | value: null 26 | custom_value: null 27 | type: meta 28 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 29 | updated_at: 1716287736 30 | --- 31 | 32 | # Gallery 33 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/en/home.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: home 3 | blueprint: pages 4 | title: Home 5 | template: default 6 | author: 45a5e4c2-35eb-437e-82f2-36e31de8d660 7 | seotamic_meta: 8 | title: 9 | append: true 10 | prepend: true 11 | type: custom 12 | value: asd 13 | custom_value: asd 14 | description: 15 | value: null 16 | custom_value: null 17 | type: empty 18 | seotamic_robots_none: false 19 | seotamic_social: 20 | title: 21 | type: title 22 | value: Home 23 | custom_value: null 24 | description: 25 | value: null 26 | custom_value: null 27 | type: meta 28 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 29 | updated_at: 1716357553 30 | --- 31 | 32 | # Welcome 33 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/en/protected_entity.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: protected 3 | blueprint: pages 4 | title: protected_entity 5 | template: default 6 | author: 45a5e4c2-35eb-437e-82f2-36e31de8d660 7 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 8 | updated_at: 1716357553 9 | protect: logged_in 10 | --- 11 | 12 | # Protected 13 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/it/contact.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: f730f3e2-7cdd-4012-9ea1-186ee5728c9e 3 | origin: 7623f560-baef-45eb-9ece-818c6f30eaec 4 | title: Contattaci 5 | seotamic_meta: 6 | title: 7 | append: true 8 | prepend: true 9 | type: title 10 | value: Contact 11 | custom_value: null 12 | description: 13 | value: null 14 | custom_value: null 15 | type: empty 16 | seotamic_social: 17 | title: 18 | type: title 19 | value: Contact 20 | custom_value: null 21 | description: 22 | value: null 23 | custom_value: null 24 | type: meta 25 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 26 | updated_at: 1716288656 27 | --- 28 | 29 | # Contattaci 30 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/it/gallery.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 850e504b-7f95-4576-9b28-fcb01e02d2e4 3 | origin: 04ed7a1e-f538-49c0-85ab-c42669d11cd2 4 | title: Galleria 5 | seotamic_meta: 6 | title: 7 | append: true 8 | prepend: true 9 | type: title 10 | value: Gallery 11 | custom_value: null 12 | description: 13 | value: null 14 | custom_value: null 15 | type: empty 16 | seotamic_social: 17 | title: 18 | type: title 19 | value: Gallery 20 | custom_value: null 21 | description: 22 | value: null 23 | custom_value: null 24 | type: meta 25 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 26 | updated_at: 1716288676 27 | --- 28 | 29 | # Galleria 30 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/it/home.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 8b94737d-db0a-4605-88f1-97d7748ec1aa 3 | origin: home 4 | title: "Casa mia" 5 | seotamic_meta: 6 | title: 7 | append: true 8 | prepend: true 9 | type: title 10 | value: Home 11 | custom_value: null 12 | description: 13 | value: null 14 | custom_value: null 15 | type: empty 16 | seotamic_social: 17 | title: 18 | type: title 19 | value: Home 20 | custom_value: null 21 | description: 22 | value: null 23 | custom_value: null 24 | type: meta 25 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 26 | updated_at: 1716288621 27 | --- 28 | 29 | # Questa e la pagina italiana 30 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/sl/contact.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: f4d21fa4-75ab-473d-bfef-d84a86e0f982 3 | origin: 7623f560-baef-45eb-9ece-818c6f30eaec 4 | title: "Kontaktirajte nas" 5 | seotamic_meta: 6 | title: 7 | append: true 8 | prepend: true 9 | type: title 10 | value: Contact 11 | custom_value: null 12 | description: 13 | value: null 14 | custom_value: null 15 | type: empty 16 | seotamic_social: 17 | title: 18 | type: title 19 | value: Contact 20 | custom_value: null 21 | description: 22 | value: null 23 | custom_value: null 24 | type: meta 25 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 26 | updated_at: 1716288667 27 | --- 28 | 29 | # Kontaktirajte nas 30 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/sl/gallery.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 5a923c59-0146-4e5f-994f-f0e46875002d 3 | origin: 04ed7a1e-f538-49c0-85ab-c42669d11cd2 4 | title: Galerija 5 | seotamic_meta: 6 | title: 7 | append: true 8 | prepend: true 9 | type: title 10 | value: Gallery 11 | custom_value: null 12 | description: 13 | value: null 14 | custom_value: null 15 | type: empty 16 | seotamic_social: 17 | title: 18 | type: title 19 | value: Gallery 20 | custom_value: null 21 | description: 22 | value: null 23 | custom_value: null 24 | type: meta 25 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 26 | updated_at: 1716288680 27 | --- 28 | 29 | # Galerija 30 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/pages/sl/home.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: ab5cd389-070f-41b3-a004-e2861d1fc325 3 | origin: home 4 | title: "Moj dom" 5 | seotamic_meta: 6 | title: 7 | append: true 8 | prepend: true 9 | type: title 10 | value: Home 11 | custom_value: null 12 | description: 13 | value: null 14 | custom_value: null 15 | type: empty 16 | seotamic_social: 17 | title: 18 | type: title 19 | value: Home 20 | custom_value: null 21 | description: 22 | value: null 23 | custom_value: null 24 | type: meta 25 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 26 | updated_at: 1716288625 27 | --- 28 | 29 | ## Slovenska 30 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/protected.yaml: -------------------------------------------------------------------------------- 1 | title: Protected 2 | sites: 3 | - en 4 | revisions: false 5 | route: 'protected_collection/{parent_uri}/{slug}' 6 | structure: 7 | root: false 8 | inject: 9 | protect: logged_in -------------------------------------------------------------------------------- /tests/__fixtures__/content/collections/protected/en/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: protected_collection 3 | id: protected_collection_test 4 | slug: test 5 | blueprint: protected 6 | updated_by: 45a5e4c2-35eb-437e-82f2-36e31de8d660 7 | updated_at: 1716357900 8 | --- 9 | 10 | # Protected Collection 11 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/globals/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnj-digital/seotamic/0917b76a0564f27f40092f11e103d6d0e0032a57/tests/__fixtures__/content/globals/.gitkeep -------------------------------------------------------------------------------- /tests/__fixtures__/content/navigation/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnj-digital/seotamic/0917b76a0564f27f40092f11e103d6d0e0032a57/tests/__fixtures__/content/navigation/.gitkeep -------------------------------------------------------------------------------- /tests/__fixtures__/content/navigation/menu.yaml: -------------------------------------------------------------------------------- 1 | title: Menu 2 | collections: 3 | - pages 4 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/seotamic_en_US.yaml: -------------------------------------------------------------------------------- 1 | section_title: null 2 | title_prepend: null 3 | title_append: '- Something' 4 | search_preview: null 5 | social_site_name: 'Demo Site' 6 | section_social: null 7 | social_title: 'General social title' 8 | social_description: null 9 | social_image: null 10 | social_image_compress: true 11 | social_preview: null 12 | open_graph_display: true 13 | twitter_display: true 14 | preview_domain: demo.com 15 | robots_none: false 16 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/taxonomies/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnj-digital/seotamic/0917b76a0564f27f40092f11e103d6d0e0032a57/tests/__fixtures__/content/taxonomies/.gitkeep -------------------------------------------------------------------------------- /tests/__fixtures__/content/trees/collections/en/pages.yaml: -------------------------------------------------------------------------------- 1 | tree: 2 | - 3 | entry: home 4 | - 5 | entry: 7623f560-baef-45eb-9ece-818c6f30eaec 6 | - 7 | entry: 04ed7a1e-f538-49c0-85ab-c42669d11cd2 8 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/trees/collections/it/pages.yaml: -------------------------------------------------------------------------------- 1 | tree: 2 | - 3 | entry: 8b94737d-db0a-4605-88f1-97d7748ec1aa 4 | - 5 | entry: f730f3e2-7cdd-4012-9ea1-186ee5728c9e 6 | - 7 | entry: 850e504b-7f95-4576-9b28-fcb01e02d2e4 8 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/trees/collections/sl/pages.yaml: -------------------------------------------------------------------------------- 1 | tree: 2 | - 3 | entry: ab5cd389-070f-41b3-a004-e2861d1fc325 4 | - 5 | entry: f4d21fa4-75ab-473d-bfef-d84a86e0f982 6 | - 7 | entry: 5a923c59-0146-4e5f-994f-f0e46875002d 8 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/trees/navigation/en/menu.yaml: -------------------------------------------------------------------------------- 1 | tree: 2 | - 3 | id: ce452848-dc90-4c91-b7fe-98ce10dc74a3 4 | entry: home 5 | - 6 | id: 9988f02a-b3bf-484c-94de-0c08ac12c3de 7 | entry: 7623f560-baef-45eb-9ece-818c6f30eaec 8 | - 9 | id: 7cebcfc5-35d8-4998-8d47-f7ef16790e01 10 | entry: 04ed7a1e-f538-49c0-85ab-c42669d11cd2 11 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/trees/navigation/it/menu.yaml: -------------------------------------------------------------------------------- 1 | tree: 2 | - 3 | id: d45af6f5-30a6-45af-93a6-158d665cc78d 4 | entry: 8b94737d-db0a-4605-88f1-97d7748ec1aa 5 | - 6 | id: a9be172a-ddd9-4087-bb30-4adb0c034e61 7 | entry: f730f3e2-7cdd-4012-9ea1-186ee5728c9e 8 | - 9 | id: f134e754-dbd0-4403-b435-74b6ccf44ad7 10 | entry: 850e504b-7f95-4576-9b28-fcb01e02d2e4 11 | -------------------------------------------------------------------------------- /tests/__fixtures__/content/trees/navigation/sl/menu.yaml: -------------------------------------------------------------------------------- 1 | tree: 2 | - 3 | id: 824a5a8f-12eb-4b20-9b8f-da2b5eeb4baf 4 | entry: ab5cd389-070f-41b3-a004-e2861d1fc325 5 | - 6 | id: b6aefb9a-20d9-46d2-b05f-fc614b2e726f 7 | entry: f4d21fa4-75ab-473d-bfef-d84a86e0f982 8 | - 9 | id: 7f47fde5-6867-4c74-b127-b90110dc04ad 10 | entry: 5a923c59-0146-4e5f-994f-f0e46875002d 11 | -------------------------------------------------------------------------------- /tests/__fixtures__/sites.yaml: -------------------------------------------------------------------------------- 1 | en: 2 | name: English 3 | url: / 4 | locale: en_US 5 | sl: 6 | name: Slovenian 7 | url: /sl/ 8 | locale: sl_SI 9 | it: 10 | name: Italian 11 | url: /it/ 12 | locale: it_IT 13 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import laravel from "laravel-vite-plugin"; 3 | import vue from "@vitejs/plugin-vue2"; 4 | import inject from "@rollup/plugin-inject"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | laravel({ 9 | input: ["resources/js/cp.js", "resources/css/cp.css"], 10 | publicDirectory: "resources/dist", 11 | }), 12 | vue(), 13 | inject({ 14 | Vue: "vue", 15 | include: "resources/js/**", 16 | }), 17 | ], 18 | resolve: { 19 | alias: { 20 | vue: "vue/dist/vue.esm.js", 21 | }, 22 | }, 23 | }); 24 | --------------------------------------------------------------------------------