# Query API :iframe{allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" frameBorder="0" referrerPolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/bwj6e1pOZAY?si=5KY_xz6v2aRYChsQ" title="Query API in 100 seconds"} ## Features - **Comprehensive Content API**: Supports querying for addresses, assets, entries, and users, providing a complete Content API for Craft CMS. - **Bearer Token Auth**: Define schemas and bearer tokens in the controlpanel to control who can access your data. - **Get Only the Data You Need**: Avoid overfetching by using a custom function in the query builder to select only the fields you require. - **Export TypeScript Types**: You can easiliy export types based on fields and element with a command and use them directly. - **Pretty JSON Responses**: JSON Transformers prettify the response for better readability. - **Native and Custom Field Detection**: Automatically detects native Craft CMS fields and custom fields across all element types. - **Prerendering Helper**: Fetch all active page URLs for prerendering, static site generation. - **Optimized Data Retrieval**: High-performance content access with smart caching strategies ensures your queries run fast. - **Extensible**: You can add your own Json Transformer and custom element types to the Query API. - **Support for native Asset Transforms**: When querying images, it can return the optimized srcset generated by Craft Asset Transforms. - **ImagerX Support**: When querying images, it can return the optimized srcset generated by ImagerX. ## Usage in Frontend The Craft Query API Plugin is highly adaptable. It comes with npm packages for Vue, Nuxt, React, Next.js and TypeScript, making integration seamless. These packages enable developers to easily use the query builder to fetch data dynamically from your frontend with minimal configuration. Example in Nuxt: ```ts [app.vue] const currentSite = useCraftCurrentSite() const uri = useCraftUri() const { data, error } = await useCraftEntry() .siteId(currentSite.value.id) .uri(uri.value) .section('news') .all() if (error.value) { console.error(error.value) } ``` This approach simplifies the process of querying Craft CMS with JavaScript, making it a powerful tool for headless development. ## Available Frontend SDKs The Craft Query API offers several SDks for modern frontend frameworks: :content-snippet{slug="available-sdks"} ## Access Control with Schemas Schemas allow you to define fine-grained access control for your data. Each access token is associated with one schema, which defines what content the token can access. This ensures a secure and predictable way of querying data via the API. When creating a schema, you can assign permissions for specific content types, such as: - Site - Section - Volume - User Group - Address - Custom element types This setup gives you full flexibility to tailor access to your needs - for example, restricting a token to only a specific section or user group. Here's an example of what a schema configuration might look like: ![Query API schema configuration](https://samuelreichor.at/images/bitmap/query-api-schema.png) And that's how you define tokens: ![Query API token configuration](https://samuelreichor.at/images/bitmap/query-api-token.png) ## Why not GraphQl? ### Productivity GraphQL requires detailed schemas, fragments, which can feel counterproductive for developers focused on efficiency. ### Steep Learning Curve If you're new to GraphQL, the syntax, schema design, error handling and tooling like Apollo can take significant time to learn. The Craft Query API Plugin works right out of the box like you were used to in twig. ### Overhead and Architectural Complexity Flexibility in GraphQL leads to a lot things you have to think of. Maintainability and error handling is hard with graphQl. The Craft CMS Query API eliminates this overhead. # Introduction The Query API extends your Craft CMS project with endpoints, allowing you to query Addresses, Assets, Entries, and Users using simple URL parameters. The plugin speeds up and simplifies headless development with Craft CMS, so you as a developer can focus on the important parts of your application. ## Frontend SDKs It’s designed to make headless development incredibly fast and flexible, especially when paired with our SDKs. These libraries let you build queries in JavaScript using a query builder that feels just like Craft’s native Twig syntax. :content-snippet{slug="available-sdks"} ## Requirements - Requires Craft CMS 5.0.0 or later. - PHP 8.2 or later. - For optimal image performance, it's highly recommended to use [ImagerX](https://imager-x.spacecat.ninja/overview.html){rel=""nofollow""}. ## Supported Element Types - Addresses - Assets - Entries - Users ::alert{variant="note"} To access any API endpoint, you must include a valid access token. This enforces secure, scoped access to your Craft CMS data. :: ## Need Help? If you encounter bugs or have feature requests, please [submit an issue](https://github.com/samuelreichor/craft-query-api/issues/new){rel=""nofollow""}. Your feedback helps improve the library! # Installation ## Craft CMS Setup Set up a Craft CMS > 5 project. You could use that [Guide](https://craftcms.com/docs/getting-started-tutorial/install/){rel=""nofollow""}. ## Using a Template You can try it out by using our CLI. This will setup a headless project in seconds. Just paste this command in your favorite terminal and get started. ```bash npx create-query-api@latest ``` :content-snippet{slug="login-credentials"} ## Install With ddev: ```bash ddev composer require samuelreichor/craft-query-api && ddev craft plugin/install query-api ``` With php: ```bash composer require samuelreichor/craft-query-api && php craft plugin/install query-api ``` ## Create Access Token You can use this command `php craft query-api/default/create-public-token` to automatically add a public access token with the according public schema. Then copy the access token. ## Finish up You can test it now by hitting that endpoint with a curl or by using tools like bruno or postman. ```bash curl --request GET \ --url 'https://your-site.ddev.site/v1/api/queryApi/customQuery' \ --header 'authorization: Bearer your-access-token' ``` You should get an empty array as response: `[]`. # First Steps Here you find a brief overview of the first steps I always do after installing the Query API. These are all optional but I think they make sense. ## Configure Cors Origins ::alert{variant="note"} If you're using a reverse proxy (with some Nginx magic) and serve both frontend and backend from the same domain, you usually don't need to configure this. You can see an example of this setup in the [craft-nuxt starter repository](https://github.com/samuelreichor/craft-nuxt-starter){rel=""nofollow""}. :: To prevent CORS origin errors in your frontend, you should configure allowed origins in the `config/app.web.php` file. Add the following configuration to define which domains are allowed to make cross-origin requests: ```php [ 'class' => \craft\filters\Cors::class, // Add your origins here 'cors' => [ 'Origin' => [ 'http://localhost:3000', ], 'Access-Control-Request-Method' => ['GET'], 'Access-Control-Request-Headers' => ['*'], 'Access-Control-Allow-Credentials' => true, 'Access-Control-Max-Age' => 86400, 'Access-Control-Expose-Headers' => [], ], ], ]; ``` ## Headless Mode Set headless mode in your `config/general.php` to true. ::alert{variant="note"} Read more about the [headless mode](https://craftcms.com/docs/getting-started-tutorial/more/graphql.html#optional-enable-headless-mode){rel=""nofollow""}. :: ## Enable Preview To enable previewing entries etc. you have to tell Craft CMS, where the frontend lives. ### Add a new env var: ```php [.env] WEBSITE_URL="http://localhost:3000" ``` ### Add new alias to Craft CMS: Now add this recently created env var to your `config/general.php` as an alias. ```php [config/general.php] ->aliases([ '@websiteUrl' => getenv('WEBSITE_URL'), ]) ``` ### Change Site URL And finally go to your control panel settings -> sites -> and change the base URL of your Site. If you click on the Craft CMS logo in the left top corner, you should land on your defined `WEBSITE_URL` ## Native Image Transforms Currently the Query API supports Craft's native image transforms and ImagerX out of the box. If you are using ImagerX, you can continue reading in the [ImagerX Guide](https://samuelreichor.at/libraries/craft-query-api/integrations/imager-x). As of now Craft does not support a global definition of srcSet's, but we need to define these somewhere. You can do that by adding a file named `query-api.php` in the `config` folder. Here you can configure the Query API and define image srcSets per image transform. This can look like that: ```php [config/query-api.php] [ 'portrait' => [ 'srcset' => ['100w', '200w'], 'generateOnSaveVolumes' => ['graphics'] ], 'landscape' => [ 'srcset' => ['0.5x', '1x'], 'generateOnSaveVolumes' => true, ], ], ]; ``` ::alert{variant="note"} You can find more about this in the [settings page](https://samuelreichor.at/libraries/craft-query-api/usage/settings#assettransforms). :: ### `generateTransformsBeforePageLoad` Another thing that makes sense is to set the `generateTransformsBeforePageLoad` setting in the `config/general.php` to `true`. This makes sure, image generation happens before the response comes back. You can read more about that in the [offical docs of craft](https://craftcms.com/docs/5.x/reference/config/general.html#generatetransformsbeforepageload){rel=""nofollow""}. ## Enable Typescript Generation The Query API comes with some black magic that generates TypeScript definitions based on your project yamls. It offers settings to automatically regenerate this TypeScript file whenever the project config changes. To set this up, you need to have your frontend in the same repository. Otherwise it get's tricky to copy the TypeScript file to the right place. Start by adding a file named `query-api.php` in the `config` folder. ```php [], 'dev' => [ // Automatic regeneration on project config changes 'typeGenerationMode' => 'auto', // // Set the path where the generated TS file should be saved 'typeGenerationOutputPath' => '@root/frontend/shared/types/base.ts', ] ]; ``` ::alert{variant="note"} You can read more about that in the [TypeScript and Craft CMS](https://samuelreichor.at/blogs/craft-typescript) blog, to get an idea how to work with it. :: # ImagerX ## Image Generation If you're using ImagerX (which I highly recommend), you'll need to generate all image transforms before querying them. Otherwise, the first fetch may take some time as all images will be generated during the initial request. To do this, create an `imager-x-generate.php` file in your `./config` folder, listing all the named transforms. Here's an example of what that file might look like: ```php [imager-x-generate.php] [ 'images' => ['auto', 'square', 'landscape', 'portrait', 'dominantColor'], ] ]; ``` ::alert{variant="note"} The plugin will automatically detect the named transforms and widths defined in your `imager-x-transforms.php`. The response will include an object where the keys are the transform names, and the values are the `srcset` of all defined transforms. :: ## Add Imager Url If your frontend and backend are not accessible under the same URL, you’ll need to configure an [Imager URL](https://imager-x.spacecat.ninja/configuration.html#imagerurl-string-array){rel=""nofollow""}. This ensures that your `srcset` attributes use absolute URLs instead of the default relative ones. This is particularly important in development environments, especially if you’re not using a reverse proxy (e.g., in DDEV) to serve your frontend. # SEOmatic ## Configure SEOmatic SEOmatic provides its own endpoints, which can be enabled in the plugin settings. I highly recommend checking out the [documentation](https://nystudio107.com/docs/seomatic/advanced.html#headless-spa-api){rel=""nofollow""} for more details. ::alert{variant="important"} The values of SEOmatic fields are filtered out of the response due to the endpoints mentioned above. :: # Usage You can fetch elements (such as addresses, assets, entries, and users) using the `customQuery` endpoint. If you need to retrieve all routes, the `allRoutes` endpoint is available. ::alert{variant="note"} For more details, check out the [customQuery](https://samuelreichor.at/libraries/craft-query-api/endpoints/custom-query) and [allRoutes](https://samuelreichor.at/libraries/craft-query-api/endpoints/all-routes) documentation. :: You can either manually build your URLs or leverage one of the following SDKs for JavaScript frameworks: :content-snippet{slug="available-sdks"} ## Build Your Own If your favorite framework isn’t listed here, you can use the [JS SDK](https://samuelreichor.at/libraries/js-craftcms-api) to create custom queries tailored to your specific needs. Feel free to reach out if you need any help! :) # Caching The API endpoints are blazingly fast. 🚀 ## Caching Strategy The plugin utilizes Craft's built-in caching mechanism, similar to the `{% cache %}` tag in Twig. Caches are automatically invalidated when any element in the request changes. The cache will persist based on the `cacheDuration` setting defined in your configuration. You can adjust this in your [Craft CMS configuration](https://craftcms.com/docs/5.x/reference/config/general.html#cacheduration){rel=""nofollow""}. You can learn how to override that in the [Settings Page](). ::alert{variant="note"} A new cache is created when a previously unqueried element is requested. However, simply changing the order of your GET parameters will NOT generate a new cache. :: ## Eager Loading To optimize the initial cache generation, eager loading is used to reduce the number of database queries. This is handled automatically through an internal eager loading map, so no additional configuration is required. You should however try to reuse relational fields to keep things scalable. # Settings You can define a multi environment aware config in `/config/query-api.php`. You can find an [example in that file](https://github.com/samuelreichor/craft-query-api/blob/main/src/config.php){rel=""nofollow""}. ## `assetTransforms` Define named image transforms with srcset configurations. The key is the handle of the named image transform. ```php return [ '*' => [ 'assetTransforms' => [ 'portrait' => [ 'srcset' => ['100w', '200w'], // define a srcset 'generateOnSaveVolumes' => ['graphics'], // auto generate images for volume "graphics" ], 'landscape' => [ 'srcset' => ['1x', '2x'], // define a srcset 'generateOnSaveVolumes' => true, // auto generate images for all volumes ], ], ] ] ``` ::alert{variant="note"} Native image transforms are not automatically detected by the Query API. If you want a srcSet to be included in the response, you need to define this setting explicitly. Otherwise, there's no reliable way for the API to know which srcSet should be generated. If ImagerX is installed and enabled, this get's ignored. :: ## `cacheDuration` Defines the cache duration. Defaults to the cache duration defined in your `general.php`. ```php return [ 'production' => [ 'cacheDuration' => 3600, // cache for 1h ] ] ``` ## `excludedFieldClasses` Define field classes that should be excluded from the json response. Used for example for excluding the seo settings field, because SEOmatic has its own API endpoint for that. ```php return [ 'production' => [ 'excludeFieldClasses' => ['nystudio107\seomatic\fields\SeoSettings'], ] ] ``` ## `includeAllEntry` Defines how entry relations from an Entries field are returned. If enabled, the `customQuery` endpoint will include full entry objects, otherwise only minimal data (title, URI, ID, slug) is returned. ```php return [ '*' => [ 'includeAllEntry' => true, ] ] ``` ::alert{variant="warning"} If you use this, be sure that you don't have circular entry relations. This would end up in an endless loop. :: ## `typeGenerationMode` Determines how ts types for your frontend should be created. ### Manuel Set it to `manuel` if you want to create your type definitions manually with the `craft query-api/generate-types` command. ```php return [ 'dev' => [ 'typeGenerationMode' => 'manual', ] ] ``` ### Auto Set it to `auto` if you want to recreate your type definitions on demand, everything your project config changes. ```php return [ 'dev' => [ 'typeGenerationMode' => 'auto', ] ] ``` ::alert{variant="warning"} This will only run if you change the project config through the Craft CMS control panel. If you run `craft project-config/apply` it will NOT run. Then you have to do it manually with the `craft query-api/generate-types` command. :: ## `typeGenerationOutputPath` Defines where ts definitions get created. Aliases can be used here as well. ```php return [ 'dev' => [ 'typeGenerationOutputPath' => '@root/queryApiTypes.ts', ] ] ``` # Commands ## `create-public-schema` Creates a public schema. ::code-group ```bash [ddev] ddev craft query-api/default/create-public-schema ``` ```bash [php] php craft query-api/default/create-public-schema ``` :: ## `create-public-token` Creates a public schema along with an access token. This will fail if a schema or token with the same identifier already exists. ::code-group ```bash [ddev] ddev craft query-api/default/create-public-token ``` ```bash [php] php craft query-api/default/create-public-token ``` :: ## `clear-caches` This will clear all data caches managed by the Query API. ::code-group ```bash [ddev] ddev craft query-api/default/clear-caches ``` ```bash [php] php craft query-api/default/clear-caches ``` :: ## `generate-types` This will generate TypeScript types based on your elements and fields. ::code-group ```bash [ddev] ddev craft query-api/typescript/generate-types ``` ```bash [php] php craft query-api/typescript/generate-types ``` :: ### Options `--output`: Define an output path, where the file should be stored. This will also accept craft aliases. **Example:** ```bash ddev craft query-api/typescript/generate-types --output=./your-path/yourFile.ts ``` ## `image-transforms` Bulk generate image transforms, for native craft asset transforms. ::code-group ```bash [ddev] ddev craft query-api/image-transforms/generate ``` ```bash [php] php craft query-api/image-transforms/generate ``` :: ### Options `--transforms`: Define one or more transform handles. :br`--volumes`: Define one or more volume handles. **Example:** ```bash ddev craft query-api/image-transforms/generate --transforms=portrait --volumes=images,graphics ``` # customQuery The `customQuery` endpoint allows you to query addresses, assets, entries, and users directly via URL parameters. It returns a JSON response for easy consumption. To use it, simply send a GET request to: `${PRIMARY_SITE_URL}/v1/api/queryApi/customQuery` with some query params. This could look like that `/v1/api/queryApi/customQuery?elementType=entries§ion=home&one=1`. A full example with an access token might look like this: ```bash curl --request GET \ --url 'https://your-site.ddev.site/v1/api/queryApi/customQuery?elementType=entries§ion=home&one=1' \ --header 'authorization: Bearer your-access-token' ``` ## GET Params Each element type has its own set of available GET parameters. This ensures precise control over the query and enhances security. Internally, these parameters are filtered to prevent potential exploits. ## Special Parameters Below is a list of special GET parameters that are available in all element types. | Params | Description | Possible Values | | --------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | all | Fetch all elements | 1 | | elementType | Specify the element type to query | addresses, assets, entries, users | | includeAllEntry | Whether to include the full data of entries or just the minimal fields (title, URI, ID, and slug). | 1 for true and 0 for false | | fields | Query specific field data by handle | string or array of field handles, use dot notation to filter out nested data, use \* as a wildcard | | one | Fetch a single element | 1 | ::alert{variant="note"} The following parameters are required: `elementType` and either `one` or `all`. :: ### `fields` The `fields` param is a simple filter, to minify the response returned to the client. You can use a `*` in combination with `fieldhandle.nestedFieldHandle`, to filter out stuff that is not important. ### `includeAllEntry` The `includeAllEntry` defines how entry relations from an Entries field are returned. If enabled, the `customQuery` endpoint will include full entry objects, otherwise only minimal data (title, URI, ID, slug) is returned. To enable this setting globally you can use the [includeAllEntry setting](https://samuelreichor.at/libraries/craft-query-api/usage/settings#includeallentry) ::alert{variant="warning"} If you use the `includeAllEntry` param, be sure that you don't have circular entry relations. This would end up in an endless loop. :: ## Addresses Below is a list of all available GET parameters for the `addresses` element type: | Params | Element type | | ------------ | ------------ | | addressLine1 | addresses | | addressLine2 | addresses | | addressLine3 | addresses | | fixedOrder | all | | fullName | addresses | | id | all | | limit | all | | locality | addresses | | offset | all | | orderBy | all | | organization | addresses | | search | all | ## Assets Below is a list of all available GET parameters for the `assets` element type: | Params | Element type | | ---------- | ------------ | | filename | assets | | fixedOrder | all | | id | all | | kind | assets | | limit | all | | offset | all | | orderBy | all | | search | all | | site | assets | | siteId | assets | | volume | assets | ## Entries Below is a list of all available GET parameters for the `entries` element type: | Params | Element type | | --------------- | ------------ | | fixedOrder | all | | id | all | | level | entries | | limit | all | | offset | all | | orderBy | all | | postDate | entries | | relatedTo | entries | | notRelatedTo | entries | | andRelatedTo | entries | | andNotrelatedTo | entries | | search | all | | section | entries | | sectionId | entries | | site | entries | | siteId | entries | | slug | entries | | status | all | | type | entries | | uri | entries | ## Users Below is a list of all available GET parameters for the `users` element type: | Params | Element type | | ---------- | ------------ | | admin | users | | authorOf | users | | email | users | | fixedOrder | all | | fullName | users | | group | users | | groupId | users | | hasPhoto | users | | id | all | | limit | all | | offset | all | | orderBy | all | | search | all | | status | all | # allRoutes The `allRoutes` endpoint retrieves all active routes for a specified `siteId` or for all sites. This is particularly useful for prerendering your pages. ## Basic Usage To use this endpoint, send a GET request to `${PRIMARY_SITE_URL}/v1/api/queryApi/allRoutes` to receive an array of routes for all sites. If you want to fetch routes for a specific site, append the `siteId` to the URL. For example for siteId = 1 you could use the endpoint like that: `${PRIMARY_SITE_URL}/v1/api/queryApi/allRoutes/1`. A full example with an access token might look like this: ```bash curl --request GET \ --url 'https://your-site.ddev.site/v1/api/queryApi/allRoutes' \ --header 'authorization: Bearer your-access-token' ``` ## Advanced Usage You can pass an array as a URL parameter to the `allRoutes` endpoint by encoding the array as a JSON string and then URL encoding it. This allows you to retrieve routes for multiple `siteIds` in a single request. ### Example To fetch routes for `siteId` 1 and 2: ```plaintext https://example.com/v1/api/queryApi/allRoutes?siteIds=%5B1%2C2%5D ``` #### Breakdown: - `https://example.com/v1/api/queryApi/allRoutes`: Base URL of your API endpoint. - `?siteIds=`: Query parameter indicating you’re passing siteIds. - `%5B1%2C2%5D`: URL-encoded JSON string [1,2]. - `%5B` is \[ - `%2C` is , - `%5D` is ] # Custom Transformers You can use the `EVENT_REGISTER_FIELD_TRANSFORMERS` event to define custom transformers for specific field types. A transformer is responsible for processing the data for your json response for a given field. Here's how to set it up: ## Initialize Event Listener Add the following code to your module or plugin to register your custom transformer: ```php [Queryapiextension.php] use modules\queryapiextension\transformers\HyperTransformer; use samuelreichoer\queryapi\transformers\BaseTransformer; use samuelreichoer\queryapi\events\RegisterFieldTransformersEvent; Event::on( BaseTransformer::class, BaseTransformer::EVENT_REGISTER_FIELD_TRANSFORMERS, function (RegisterFieldTransformersEvent $event) { $event->transformers[] = [ 'fieldClass' => 'verbb\hyper\fields\HyperField', // class of your field you want to transform 'transformer' => HyperTransformer::class, // class of your transformer ]; } ); ``` In this example: - `fieldClass` specifies the fully qualified class name of the field you want to transform. - `transformer` defines the custom transformer class that will handle the transformation. ## Creating a Transformer A transformer class processes the field's data. Here’s an example for a `HyperField` transformer: ```php [HyperTransformer.php] hyper = $hyper; } /** * Transforms the Hyper field data. * * @return array */ public function getTransformedData(): array { return [ 'metadata' => $this->getMetaData(), 'linkText' => $this->hyper->text, 'linkUrl' => $this->hyper->url, 'linkTarget' => $this->hyper->target, ]; } /** * Retrieves metadata from the Hyper field. * * @return array */ protected function getMetaData(): array { return [ 'type' => $this->hyper->type, ]; } } ``` ### Extending the Base Transformer If your custom transformer requires shared functionality, you can extend the `BaseTransformer` class to inherit common logic. For example: ```php use samuelreichoer\queryapi\transformers\BaseTransformer; class HyperTransformer extends BaseTransformer { public function getTransformedData(): array { return [ 'linkText' => $this->hyper->text, 'linkUrl' => $this->hyper->url, ]; } } ``` ## Important Notes 1. **Method Naming**: The plugin uses the `getTransformedData()` method to fetch transformed data. This method is **required** in all custom transformers. 2. **Error Logging**: If a transformer is not properly registered or does not implement `getTransformedData()`, an error will be logged, but it will not throw it. # Custom Element Types You can use the `EVENT_REGISTER_ELEMENT_TYPES` event to define custom element types. An element Type has it's own transformer and query. We will try that out with the awesome [Navigation Plugin](https://verbb.io/craft-plugins/navigation/features){rel=""nofollow""}. Here's how to set it up: ## Initialize Event Listener Add the following code to your module or plugin to register your custom transformer: ```php [./modules/queryapiextension/Queryapiextension.php] use modules\queryapiextension\transformers\NavigationTransformer; use samuelreichoer\queryapi\events\RegisterElementTypesEvent; use samuelreichoer\queryapi\models\RegisterElementType; use samuelreichoer\queryapi\services\ElementQueryService; Event::on( ElementQueryService::class, ElementQueryService::EVENT_REGISTER_ELEMENT_TYPES, function (RegisterElementTypesEvent $event) { $event->elementTypes[] = new RegisterElementType([ 'elementTypeClass' => 'verbb\navigation\elements\Node', 'elementTypeHandle' => 'navigation', 'allowedMethods' => ['limit', 'handle', 'id'], 'transformer' => NavigationTransformer::class, ]); } ); ``` In this example: - `elementTypeClass` specifies the full class name of the element type you want to add. - `elementTypeHandle` defines the handle that you use later for quering from that element type. - `allowedMethods` defines all allowed methods that you can later use in the query builder. - `transformer` adds the json transformer for beautiful responses. ## Creating a Transformer The Transformer processes the data for the response. Here’s an example for the `NavigationTransformer`: ```php [./modules/queryapiextension/transformers/NavigationTransformer.php] navigation = $navigation; } /** * Transforms the Navigation Node into an array. * * @param array $predefinedFields * @return array */ public function getTransformedData(array $predefinedFields = []): array { return [ 'metadata' => $this->getMetaData(), 'title' => $this->navigation->title, 'url' => $this->navigation->getUrl(), 'type' => $this->navigation->getTypeLabel(), 'level' => $this->navigation->level, ]; } /** * Retrieves metadata from the Navigation Node. * * @return array */ protected function getMetaData(): array { return array_merge(parent::getMetaData(), [ 'id' => $this->navigation->id, 'siteId' => $this->navigation->site->id, 'status' => $this->navigation->getStatus(), ]); } } ``` ## Usage After that you should be able to query from the new added `navigation` element type like that: ```text /v1/api/queryApi/customQuery?elementType=navigation&handle=main-navigation&all=1 ``` You can use all query params that you have added through the `allowedMethods` property and all of the [special parameters](http://localhost:3000/libraries/craft-query-api/endpoints/custom-query#special-parameters){rel=""nofollow""} (e.g. one=1 and all=1). # Custom Typescript Types You can use the `EVENT_REGISTER_TYPE_DEFINITIONS` event to define custom TypeScript type definitions for specific Craft CMS field types. ## Initialize Event Listener Add the following code to your module or plugin to register your custom type definitions: ```php [Queryapiextension.php] use modules\queryapiextension\transformers\NavigationTransformer; use samuelreichoer\queryapi\events\RegisterTypeDefinitionEvent; use samuelreichoer\queryapi\models\RegisterTypeDefinition; use samuelreichoer\queryapi\services\TypescriptService; Event::on( TypescriptService::class, TypescriptService::EVENT_REGISTER_TYPE_DEFINITIONS, function (RegisterTypeDefinitionEvent $event) { $event->typeDefinitions[] = new RegisterTypeDefinition([ 'fieldTypeClass' => 'verbb\hyper\fields\HyperField', 'staticHardType' => 'export type hello = string', 'dynamicHardType' => HyperTypeService::class, 'staticTypeDefinition' => 'hello[]', 'dynamicDefinitionClass' => HyperTypeService::class, ]); } ); ``` Each RegisterTypeDefinition allows the user to define: - `fieldTypeClass`: The Craft field class this applies to (e.g., `HyperField::class`). - `staticHardType`: A custom hardcoded TypeScript type that will be injected globally (e.g., utility or shared types). It gets overwritten if dynamicHardType is defined. - `dynamicHardType`: A PHP class that can dynamically generate hardcoded types (must implement a method like `setHardTypes()`). - `staticTypeDefinition`: A fixed return type for this field (e.g., `hello[]`). It gets overwritten if dynamicHardType is defined. - `dynamicDefinitionClass`: A PHP class that will receive the field and return a context-aware type (must have a method like `setTypeByField()`). ## Creating a Transformer To set dynamic types you can create a file like the following: ```php [HyperTypeService.php] **Important Note**: You *really* want to use Git-based resources here, *not* Docker. I know, it's confusing, and I messed this up the first time too. But trust me, Git-based is what you need for things like auto-deployments when you push to your repo, and those are super handy. #### 2. Set Up the Repository Details - Now, you'll have to give Coolify the URL of your repository on GitHub. - For the Build Pack, make sure you select "Docker Compose." - Coolify should then automatically detect the `docker-compose.yaml` file in your repo. Double-check that it does! If it looks good, hit "Continue." ![Set Up the Repository Details](https://samuelreichor.at/images/bitmap/craft-coolify-1.png) #### 3. Configure the Web Container - You'll need to assign a domain to your web container. - And don't forget to tick that little checkbox in the screenshot! It's easy to miss. ![Configure the Web Container](https://samuelreichor.at/images/bitmap/craft-coolify-2.png) #### 4. Add Environment Variables - In the left sidebar of Coolify, find **Environment Variables**. - Add the following variables (Pro tip: If you enable the developer view in Coolify, it'll make adding these a lot faster!): ```plaintext CRAFT_APP_ID=CraftCMS--bc2c8733-c91a-46fe-87f3-b53b44d38c2e CRAFT_DB_DATABASE=db CRAFT_DB_DRIVER=mysql CRAFT_DB_PASSWORD=db CRAFT_DB_PORT=3306 CRAFT_DB_SCHEMA=public CRAFT_DB_SERVER=db CRAFT_DB_TABLE_PREFIX= CRAFT_DB_USER=db CRAFT_ENVIRONMENT=production CRAFT_SECURITY_KEY=qxyV2FckKZEwr91pVthQNaATzkVE41Zd FALLBACK_IMAGE=/static/images/placeholder.png PLUGIN_IMAGERX=XXXXXXXXXXXXXXXXXXXXXXXX PLUGIN_NAVIGATION=XXXXXXXXXXXXXXXXXXXXXXXX PLUGIN_QUERY_API=XXXXXXXXXXXXXXXXXXXXXXXX PLUGIN_SEOMATIC=XXXXXXXXXXXXXXXXXXXXXXXX PRIMARY_SITE_URL=https://cheap-craft.steelcity-creative.at PRIMARY_SITE_URL_DE=https://cheap-craft.steelcity-creative.at/de PRIMARY_SITE_URL_ES=https://cheap-craft.steelcity-creative.at/es NUXT_CRAFT_TOKEN=iIkWn1cMYA181On591yqdhJluAzI-r1c NUXT_ENVIRONMENT=production NUXT_PRIMARY_SITE_URL=https://cheap-craft.steelcity-creative.at NUXT_PRIMARY_SITE_URL_DE=https://cheap-craft.steelcity-creative.at/de NUXT_PRIMARY_SITE_URL_ES=https://cheap-craft.steelcity-creative.at/es ``` #### 5. Deploy! You can finally deploy your application! Just click the "Deploy" button in the top right corner. You should now see an error of Nuxt in the frontend (`/`) and an error of Craft in the backend (`/admin`). #### 6. Import Database Now you can import an existing database into the `db` container or use the `php craft setup` command in the `web` container to start fresh. #### 7. Apply Changes after Deployment - Go to the "General Configuration" and scroll down to "Pre/Post Deployment Commands." - You can put commands here that you want to run automatically after a successful deployment. For me, it's usually something like`php craft up && php craft clear-caches/all`, but you can also put the path to a script if you need to do something more complicated. ![Apply Migrations and Config Changes](https://samuelreichor.at/images/bitmap/craft-coolify-4.png) # Installation ## Requirements - Requires Craft CMS 5.0.0 or later. - PHP 8.2 or later. ## Craft Plugin Store To install Loanwords, go to the Plugin Store in your Craft control panel, search for "Loanwords," and click the Install button. ## Composer With ddev: ```bash ddev composer require samuelreichor/craft-loanwords && ddev craft plugin/install loanwords ``` With php: ```bash composer require samuelreichor/craft-loanwords && php craft plugin/install loanwords ``` # Usage ## Define Loanwords Once you've installed the Loanwords plugin, you'll gain access to a dedicated section in Craft CMS for managing your loanwords. ![Loanwords Overview](https://samuelreichor.at/images/bitmap/loanword-overview.png) When you click on `New Loanword` you add new words. After saving, you can use the `a11yTextReplacer()` Twig extension in your templates. ## Usage in Frontent After saving, you can use the `a11yTextReplacer()` Twig extension in your templates. This automatically replaces loanwords in your content with the correct `` tags. ```text [example.twig] {{ a11yTextReplacer(entry.richText) | raw }} ``` This extension takes an string as an argument. # Config ## Configuration Create a `loanwords.php` file under your `/config` directory with the following options available to you. You can also use multi-environment options to change these per environment. ```php [./config/loanwords.php] [ 'defaultLang' => 'de-AT', 'caseSensitive' => false, 'cssClass' => 'inline', ] ]; ``` - `defaultLang`: Sets the default language for loanwords, defaults to `en`. - `caseSensitive`: Makes the replacement process case-sensitive, matching capitalization exactly, defaults to `false` - `cssClass`: Defines a CSS class for styling loanwords, defaults to `position: inline` as style if left blank. ::alert{variant="caution"} This File will overwrite the settings from the control panel. :: ## Control Panel You can also manage configuration settings through the Control Panel by visiting Settings → Loanwords. # Loanwords ## Features - Manage and organize your loanwords effortlessly within Craft CMS. - Includes a Twig Extension to automatically wrap loanwords with a tag for accessibility. - Customize default language tags and CSS classes for consistent styling. - Provides case-sensitive and case-insensitive options for flexible loanword matching. ## What are loanwords? [Loanwords](https://en.wikipedia.org/wiki/Loanword){rel=""nofollow""} are words borrowed from one language and incorporated into another, often retaining their original spelling and pronunciation. In German, common loanwords are Anglicisms like "Bachelor", "Job" or "FAQ" borrowed from English. ## Why it matters? Using appropriate language tags for loanwords is essential for accessibility and semantic accuracy. These tags ensure that screen readers pronounce words correctly based on their language, providing a better experience for users with visual impairments. Additionally, proper language tagging improves SEO and helps search engines understand your content more effectively. ## How it works Once you've installed the Loanwords plugin, you'll gain access to a dedicated section in Craft CMS for managing your loanwords with ease. ![Loanwords Overview](https://samuelreichor.at/images/bitmap/loanword-overview.png) Adding a new loanword is simple—just provide the word and select the appropriate language from a dropdown menu. This ensures that each loanword is correctly tagged for accessibility and semantic accuracy. ![Loanwords Overview](https://samuelreichor.at/images/bitmap/loanword-edit.png) After saving, you can use the `a11yTextReplacer()` Twig extension in your templates. This automatically replaces loanwords in your content with the correct [tags, enhancing accessibility and ensuring proper screen reader pronunciation on the frontend.]{lang=""} # Installation ## Requirements - Supports Craft CMS 4 and 5 - PHP 8 or later. ## Craft Plugin Store To install Quick Edit, go to the Plugin Store in your Craft control panel, search for "Quick Edit," and click the Install button. ## Composer With ddev: ```bash ddev composer require samuelreichor/craft-quick-edit && ddev craft plugin/install quick-edit ``` With php: ```bash composer require samuelreichor/craft-quick-edit && php craft plugin/install quick-edit ``` # Usage After installing the Plugin you are already set up. But you can give your authors an even better experience by styling the Quick Edit button. ## Custom Styling You can personalize the button’s look and feel by adding your own custom styles. The following example will animate the Quick Edit button in if a user hovers over the left corner. ```css [app.css] .craft-quick-edit { position: fixed; top: 0; left: 0; width: 1.5rem; height: 1.5rem; z-index: 1000; a.craft-quick-edit_link { position: fixed; top: 0.5rem; left: 0.5rem; right: unset; background-color: black; color: white; padding: 6px; text-decoration: none; border-radius: 3px; transform: translateY(-100px); transition-property: opacity, transform; transition-duration: 300ms; opacity: 0; } &:hover .craft-quick-edit_link { transform: translateX(0); opacity: 1; } } ``` ![Craft Quick Edit Showcase with Animation](https://samuelreichor.at/videos/gifs/craft-quick-edit-animation.gif){style="width: 100%;border-radius:2px"} ## CSP Compatibility If your site uses Content Security Policy (CSP), you need to disable automatic injection and render the Quick Edit assets manually using Twig template tags. Set `autoInject` to `false` in your config or via the Control Panel. ### Template Tags ```twig {# Without nonce #} {{ craft.quickEdit.render() }} {# With nonce #} {{ craft.quickEdit.render(nonce) }} {# Full control - raw code without tags #}
``` ::alert{variant="caution"} CSP nonces and static caching don't work together. The nonce gets cached but the CSP header doesn't. Choose one approach: - **Static caching + no CSP nonce** → `{{ craft.quickEdit.render() }}` - **CSP nonce + no static caching** → `{{ craft.quickEdit.render(nonce) }}` :: # Config ## Configuration Create a `quick-edit.php` file under your `/config` directory with the following options available to you. You can also use multi-environment options to change these per environment. ```php [./config/quick-edit.php] [ 'isGlobalDisabled' => false, 'targetBlank' => false, 'isStandalonePreview' => false, 'linkText' => '', 'alwaysEnabled' => false, 'autoInject' => true, ], 'dev' => [ 'alwaysEnabled' => true, ] ]; ``` - `isGlobalDisabled`: Disables Quick Edit globally. So no edit link will be shown. - `targetBlank`: Opens the control panel edit link in a new window. - `isStandalonePreview`: Enable this option to open the edit link in the standalone preview mode (Only available in > Craft 5.6.0). - `linkText`: Hides the icon in the edit link and displays text instead. - `alwaysEnabled`: Enable this option for development purposes only. It bypasses all user permissions and always displays the quick edit button. - `autoInject`: Automatically injects the required JavaScript and CSS. Disable this for CSP compatibility and use the Twig template tags instead. ::alert{variant="caution"} This File will overwrite the settings from the control panel. :: ## Control Panel You can also manage configuration settings through the Control Panel by visiting Settings → Quick Edit. # Quick Edit ## Features - Automatically adds an edit page link to your frontend. - Only visible if the logged-in user has permission to save the entry. - Support for the Standalone Preview Mode added in Craft 5.6.0. - Full Support for Multisites. - Support for Craft Commerce Product Pages. - Works perfectly with pages cached by Blitz. - Completely customizable by css. - No configuration required. ## Enable your Authors a better Experience This plugin is incredibly useful for authors managing content on a website. It allows authors to quickly access the editing interface of entries directly from the frontend, but only if they have the necessary permissions to save the content. This eliminates the need for navigating through the control panel, making content management faster and more efficient. ## How it works After installing the Quick Edit plugin and logging into your control panel, a small button will appear in the right corner of your frontend. This button is only visible to logged-in users who have the necessary permission to edit the page. ![Craft Quick Edit Showcase](https://samuelreichor.at/images/bitmap/craft-quick-edit-showcase.png) You can personalize the button’s look and feel by adding your own custom styles. Additionally, you can animate it easily using just CSS to match your site's design and create an even more interactive experience. ![Craft Quick Edit Showcase with Animation](https://samuelreichor.at/videos/gifs/craft-quick-edit-animation.gif){style="width: 100%;border-radius:2px"} # Installation & Setup ## Requirements - Craft CMS 5.0.0 or later - PHP 8.2 or later ## AI-Assisted Setup If you use [Claude Code](https://claude.ai/code){rel=""nofollow""} or other AI tools, LLMify ships with a built-in setup skill. To use it paste this markdown into `.claude/skills/install-llmify/SKILL.md`. ::code-collapse ````md [.claude/skills/install-llmify/SKILL.md] --- name: install-llmify description: Install and configure the LLMify Craft CMS plugin step by step --- # Install and Configure LLMify for Craft CMS This guide tells an AI agent how to install and set up the LLMify plugin in a Craft CMS 5 project. ## Step 1: Install the Plugin Run the following commands in the Craft project root: ```bash composer require samuelreichor/craft-llmify php craft plugin/install llmify ``` If the project uses DDEV: ```bash ddev composer require samuelreichor/craft-llmify ddev craft plugin/install llmify ``` ## Step 2: Enable Sections 1. In the Craft control panel, go to **LLMify → Content**. 2. Enable each section that should produce markdown output using the **Enable for Section** toggle. 3. Set an **LLM Title** and **LLM Description** for each enabled section — these populate the `llms.txt` file. ## Step 3: Add Template Tags Wrap the content you want converted to markdown with the `{% llmify %}` tag in your Twig templates: ```twig {% llmify %}

{{ entry.title }}

{{ entry.bodyContent }}
{% endllmify %} ``` Multiple `{% llmify %}` blocks per template are supported — their content is merged into a single markdown file. To exclude specific parts within an llmify block: ```twig {% llmify %}

{{ entry.title }}

{% excludeLlmify %} {% endexcludeLlmify %}
{{ entry.bodyContent }}
{% endllmify %} ``` You can also exclude content by adding the `exclude-llmify` CSS class to any HTML element. This class name is configurable via the config file. ## Step 4: Generate Markdown Generate markdown for all enabled entries using one of these methods: - **Control Panel**: Go to **Utilities → LLMify** and trigger generation. - **Entry Sidebar**: Generate markdown for a single entry from its edit page. - **Console Command**: ```bash php craft llmify/markdown/generate ``` To clear all generated markdown and start fresh: ```bash php craft llmify/markdown/clear ``` ## Step 5: Check the Dashboard Go to **LLMify → Dashboard** to see an overview of your setup: - **Site setup score** — shows how complete your site-level configuration is (LLM title, description, note, front matter fields). - **Section statistics** — content-level stats per section showing how many entries have markdown generated. ## Step 6: Verify the Output After generating markdown, verify these URLs are accessible: - `/llms.txt` — Summary file listing all enabled entries - `/llms-full.txt` — Full content of all entries - `/.well-known/llms.txt` — RFC 8615 compliant discovery endpoint - `/raw/{entry-uri}.md` — Individual markdown page (if `markdownUrlPrefix` is set) Test auto-serve markdown with: ```bash curl -H "Accept: text/markdown" https://your-site.com/your-entry-url ``` ## Full Documentation For detailed configuration options and advanced usage, see the [LLMify documentation](https://samuelreichor.at/libraries/craft-llmify). ```` :: Then just run `/install-llmify` in your Craft project directory and the assistant will do the development setup for you. You just need to adjust the [content settings](https://samuelreichor.at/#enable-sections) for your needs. ## Craft Plugin Store To install LLMify, go to the Plugin Store in your Craft control panel, search for "LLMify," and click the Install button. ## Composer ::code-group ```bash [ddev] ddev composer require samuelreichor/craft-llmify && ddev craft plugin/install llmify ``` ```bash [php] composer require samuelreichor/craft-llmify && php craft plugin/install llmify ``` :: ## Setup ::steps ### Enable Sections In the Craft control panel, go to **LLMify > Content** and enable each section that should produce Markdown output using the **Enable for Section** toggle. Set an **LLM Title** and **LLM Description** for each enabled section, these populate the `llms.txt` file. ![LLMify Content Settings in Detail](https://samuelreichor.at/images/bitmap/craft-llmify-content-settings-detail.png) :::alert{variant="note"} Learn more about Content Settings in the [Basic Overview](https://samuelreichor.at/libraries/craft-llmify/usage/basic-overview#content-settings). ::: ### Add Template Tags Wrap the content you want converted to Markdown with the `{% llmify %}` tag in your Twig templates: ```twig {% llmify %}

{{ entry.title }}

{{ entry.bodyContent }}
{% endllmify %} ``` Multiple `{% llmify %}` blocks per template are supported, their content is merged into a single Markdown file. :::alert{variant="note"} Learn more about template tags and other content control options on the [Content Control](https://samuelreichor.at/libraries/craft-llmify/usage/content-control) page. ::: ### Generate Markdown Generate Markdown for all enabled entries using one of these methods: - **Control Panel**: Go to **Utilities > LLMify** and trigger generation. - **Entry Sidebar**: Use the Update button to generate Markdown for a single entry. - **Console Command**: ```bash php craft llmify/markdown/generate ``` ### Verify the Output After generating, these URLs should be accessible on your site: - `/llms.txt` and `/.well-known/llms.txt`: list of all URLs with descriptions - `/llms-full.txt`: full Markdown content of all entries - `/raw/{your-uri}.md`: Markdown for a single entry You can also test auto-serve and bot detection: :::code-group ```bash [Content negotiation] curl -H "Accept: text/markdown" https://your-site.com/your-entry-url ``` ```bash [Bot detection] curl -A "GPTBot/1.0" https://your-site.com/your-entry-url ``` ::: ### Add AI Bot Analytics (Optional) Want to see which AI crawlers actually hit your Markdown, how often, and which pages they read? Install [Craft Insights](https://samuelreichor.at/libraries/craft-insights) alongside LLMify and a dedicated dashboard appears in the Insights subnav. ![AI Bot Analytics dashboard powered by the LLMify and Insights integration](https://samuelreichor.at/images/bitmap/craft-insights-llmify-integration.png) :::alert{variant="note"} The integration activates automatically when both plugins are installed. See the [AI Bot Analytics](https://samuelreichor.at/libraries/craft-llmify/usage/ai-bot-analytics) page for the full setup. ::: :: You're all set. Head over to the [Basic Overview](https://samuelreichor.at/libraries/craft-llmify/usage/basic-overview) to learn about the Dashboard, Permissions, and other features. ## Support If you encounter bugs or have feature requests, [please submit an issue](https://github.com/samuelreichor/craft-llmify/issues/new){rel=""nofollow""}. Your feedback helps improve the plugin! # Configuration ## Control Panel You can manage configuration settings through the Control Panel by visiting Settings > LLMify. ## Settings You can define a multi-environment aware config in `/config/llmify.php`. Settings defined in the config file override control panel settings. ::alert{variant="caution"} Config file settings will overwrite the settings from the control panel. :: ### `isEnabled` Master toggle for Markdown creation. When disabled, no Markdown will be generated or served, and every LLMify page in the control panel shows a warning so editors know the plugin is off. ```php return [ '*' => [ 'isEnabled' => true, // default ], ]; ``` ### `headlessMode` Enable this when you use Craft headless. Markdown is then generated by fetching your front-end URLs instead of relying on Twig rendering, and exposed through the API endpoints described in [Headless](https://samuelreichor.at/libraries/craft-llmify/usage/headless). The auto-serve and discovery-tag features do not apply in this mode. ```php return [ '*' => [ 'headlessMode' => false, // default ], ]; ``` ### `apiToken` Optional token that protects the headless [API endpoints](https://samuelreichor.at/libraries/craft-llmify/usage/headless) (only applies in headless mode). Set it to an environment variable holding a long random string, e.g. generated with `openssl rand -hex 32`. Requests must then send the same value in the `X-Llmify-Token` header; leave empty to keep the endpoints unprotected. ```php return [ '*' => [ 'apiToken' => '$LLMIFY_API_TOKEN', // default: null ], ]; ``` ### `autoServeMarkdown` Automatically serve Markdown instead of HTML when the request contains an `Accept: text/markdown` header. ```php return [ '*' => [ 'autoServeMarkdown' => true, // default ], ]; ``` ### `enableBotDetection` Detect known AI bots (GPTBot, ClaudeBot, ChatGPT-User, etc.) by their user agent and automatically serve Markdown to them. See [Auto-Serve Markdown](https://samuelreichor.at/libraries/craft-llmify/usage/auto-serve) for the full list of detected bots. This is disabled by default: it forces Markdown on crawlers that did not ask for it, which some search engines consider cloaking. Content negotiation via the `Accept: text/markdown` header (`autoServeMarkdown`) stays the recommended way to serve Markdown, because there the crawler explicitly requests it. ```php return [ '*' => [ 'enableBotDetection' => false, // default ], ]; ``` ### `additionalBotUserAgents` Add custom bot user agents to detect in addition to the built-in list. ```php return [ '*' => [ 'additionalBotUserAgents' => [ ['userAgent' => 'MyCustomBot'], ['userAgent' => 'AnotherBot'], ], ], ]; ``` ### `autoInjectDiscoveryTag` Inject a `` discovery tag into the HTML head for every page that has Markdown available. ```php return [ '*' => [ 'autoInjectDiscoveryTag' => true, // default ], ]; ``` ### `enableWebMcp` Expose your enabled content to in-browser AI agents (e.g. Gemini in Chrome) via the experimental [WebMCP](https://samuelreichor.at/libraries/craft-llmify/usage/webmcp) standard. When enabled, every front-end page loads a script that registers read-only search, page, section, and navigation tools with the visitor's browser agent. ```php return [ '*' => [ 'enableWebMcp' => false, // default ], ]; ``` ### `isRealUrlLlm` Whether to use real page URLs or Markdown URLs in the `llms.txt` file. Recommended to enable together with `autoServeMarkdown`. ```php return [ '*' => [ 'isRealUrlLlm' => false, // default ], ]; ``` ### `markdownUrlPrefix` URL prefix for individual Markdown pages (e.g. `https://example.com/raw/about.md`). Leave empty to use `{url}.md` URLs directly. ```php return [ '*' => [ 'markdownUrlPrefix' => 'raw', // default ], ]; ``` ### `excludeClasses` CSS classes that should be excluded from the Markdown generation. Elements with these classes will be stripped before conversion. ```php return [ '*' => [ 'excludeClasses' => [ ['classes' => 'exclude-llmify'], // default ], ], ]; ``` ### `markdownConfig` Configuration passed to the [HTML-to-Markdown converter](https://github.com/thephpleague/html-to-markdown){rel=""nofollow""}. See the library docs for all available options. ```php return [ '*' => [ 'markdownConfig' => [ 'strip_tags' => true, 'header_style' => 'atx', 'remove_nodes' => 'img picture style form button input select option svg script nav noscript video audio source', ], // default ], ]; ``` ### `concurrentRequests` Maximum number of concurrent HTTP requests when batch generating Markdown. Valid range: 1–100. Each request renders one of your pages, so a full regeneration puts the same load on the server as that many simultaneous visitors. On small or shared hosting, lower this to `1` or `2` if a full run pushes CPU or memory too hard. ```php return [ '*' => [ 'concurrentRequests' => 3, // default ], ]; ``` ### `requestTimeout` Maximum number of seconds each request can take during batch generation. ```php return [ '*' => [ 'requestTimeout' => 100, // default ], ]; ``` ### `basicAuthUsername` and `basicAuthPassword` LLMify generates Markdown by requesting your own pages. If the site is protected with HTTP Basic Auth (common on staging), set the credentials here so those requests get through. Use environment variables so the password does not end up in project config. Leave both empty when the site is not protected. ```php return [ 'staging' => [ 'basicAuthUsername' => '$LLMIFY_AUTH_USER', // default: null 'basicAuthPassword' => '$LLMIFY_AUTH_PASS', // default: null ], ]; ``` ### `frontMatterInFullTxt` Whether front matter should be included for each page in `llms-full.txt`. ```php return [ '*' => [ 'frontMatterInFullTxt' => false, // default ], ]; ``` ## Multi-Environment Example A complete example showing environment-specific configuration: ```php [ 'isEnabled' => true, 'autoServeMarkdown' => true, 'enableBotDetection' => true, 'autoInjectDiscoveryTag' => true, 'isRealUrlLlm' => true, 'markdownUrlPrefix' => 'raw', 'concurrentRequests' => 5, 'requestTimeout' => 120, ], 'dev' => [ 'isEnabled' => false, ], 'staging' => [ 'basicAuthUsername' => '$LLMIFY_AUTH_USER', 'basicAuthPassword' => '$LLMIFY_AUTH_PASS', ], 'production' => [ 'concurrentRequests' => 10, ], ]; ``` # Basic Overview ## Dashboard The Dashboard gives you an overview of your LLMify setup. It shows a setup score for the current site and section-level content statistics, so you can quickly see which sections are configured and how many entries have been processed. ![LLMify Dashboard](https://samuelreichor.at/images/bitmap/craft-llmify-dashboard.png) ## Site Settings Site Settings is where you set default site-wide settings, on a per-site basis. This includes enabling or disabling LLMify for the site, setting the site title and description for `llms.txt`, and configuring default front matter fields that are inherited by all sections. You can also turn `llms.txt` and `llms-full.txt` off individually per site. Enable **Include Social Links** to add a `## Social` section with your social profiles to `llms.txt` and `llms-full.txt`. If SEOmatic is installed, the link table is prefilled with its "Same As URLs" and keeps following them until you save your own changes. ![LLMify Site Settings](https://samuelreichor.at/images/bitmap/craft-llmify-site-settings.png) ## Content Settings Content Settings is where you can set default content settings, on a per-section basis. ![LLMify Content Settings](https://samuelreichor.at/images/bitmap/craft-llmify-content-settings.png) The list of these content types includes status indicators identifying what's been configured for each one. ![LLMify Content Settings in Detail](https://samuelreichor.at/images/bitmap/craft-llmify-content-settings-detail.png) It is important to set LLM Title and Description for each content type, as they are used in the `llms.txt` file to provide context for the LLMs. Content Settings also lets you override the site-level front matter fields for a specific section. You can drag rows in the Content Settings list to control the order sections appear in `llms.txt` and `llms-full.txt`. The order is stored per site, so multi-site setups can use different orderings. ## Entry Settings LLMify has an LLMify Settings Field that you can add to your Entry Types. You can use this field to override the content settings for each entry, including title source, description source, and front matter fields. You can also exclude individual entries from Markdown generation entirely. ![LLMify Entry Settings](https://samuelreichor.at/images/bitmap/craft-llmify-entry.png) ### Settings Inheritance Settings follow a hierarchical inheritance model: **Site > Section > Entry**. Each level inherits from the one above and can optionally override specific values. For example, front matter fields defined at the site level are inherited by all sections, but a section can override them, and an individual entry can override the section settings. ### Sidebar Panel The sidebar panel on entry and product edit pages shows the current LLMify status for that element: - If Markdown has been generated, it shows a link to the generated file and the last update timestamp. - If LLMify is disabled at the site, section, or entry level, it shows which setting is responsible with a direct link to the relevant settings page. - The **Update** and **Clear** buttons let you regenerate or remove Markdown for individual entries. ::alert{variant="note"} Button visibility depends on user permissions. Users need the "Generate Markdown" and "Clear Markdown" permissions respectively. :: ### Preview Targets LLMify registers Markdown preview targets for entries and products. This allows content authors to preview the Markdown output directly from the entry editor using Craft's built-in preview system. ## LLMify Utility The LLMify Utility allows you to manage the Markdown generation process. You can access it in the Craft CMS control panel under Utilities. ![LLMify Utility](https://samuelreichor.at/images/bitmap/craft-llmify-utilities.png) You can use this to manually trigger the Markdown generation process for all entries that are enabled for Markdown generation and have the `llmify` tag in their templates. ## How Markdown Generation Works Whenever an entry is saved, or you trigger a full run from the utility or the [console](https://samuelreichor.at/libraries/craft-llmify/usage/console-commands), LLMify adds a job to Craft's queue. The job requests the affected pages from your own site and stores the resulting Markdown. Two things follow from that: - **The queue has to run.** If your server only processes the queue during web requests, Markdown is regenerated with a delay or not at all on quiet sites. For reliable on-save updates, run a queue daemon or a cron job that executes `php craft queue/run` regularly. - **The site has to accept the requests.** Craft's offline mode (`isSystemLive: false`) is handled automatically. If the site sits behind HTTP Basic Auth, set the [`basicAuthUsername` and `basicAuthPassword`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#basicauthusername-and-basicauthpassword) settings. Pages that cannot be fetched are skipped and logged as warnings in Craft's logs under the `llmify` category, so a misconfigured environment never fails silently. If the dashboard shows lower coverage than expected, that log is the first place to look. ## AI Bot Analytics When [Craft Insights](https://samuelreichor.at/libraries/craft-insights) is installed alongside LLMify, a dedicated dashboard for tracking AI crawlers appears in the Insights subnav. You see which bots hit your Markdown, how often, which pages they read most, and whether they arrive via direct Markdown URLs or content-negotiated responses. ![AI Bot Analytics dashboard powered by the LLMify and Insights integration](https://samuelreichor.at/images/bitmap/craft-insights-llmify-integration.png) Setup details and KPI breakdowns are on the dedicated [AI Bot Analytics](https://samuelreichor.at/libraries/craft-llmify/usage/ai-bot-analytics) page. ## Permissions LLMify provides granular user permissions (available with Craft Pro): | Permission | Description | | ------------------------- | ------------------------------------------------------------ | | **View Dashboard** | Access the LLMify dashboard | | **Edit Content Settings** | Manage per-section content settings | | **Edit Site Settings** | Manage per-site settings | | **Generate Markdown** | Trigger Markdown generation via the sidebar panel or utility | | **Clear Markdown** | Clear generated Markdown via the sidebar panel or utility | | **View Sidebar Panel** | See the LLMify sidebar panel on entry and product edit pages | You can assign these permissions to user groups under Settings > Users > User Groups in the Craft control panel. # Content Control LLMify gives you multiple ways to control what ends up in your Markdown output: Twig template tags, CSS class exclusion, and per-entry settings via the LLMify Settings Field. ## Template Tags To select content for Markdown generation, you can use the `llmify` and `excludeLlmify` tags in your templates. These tags allow you to specify which parts of your content should be converted to Markdown and which parts should be excluded. ## `llmify` The body of `llmify` tags will be converted to markdown. You can use multiple `llmify` tags in your templates. They get merged together and the result is saved as a markdown file. ```twig [templates/entry.twig] {% llmify %}

My Title

My content

{% endllmify %} {% llmify %}

More content

{% endllmify %} ``` This will result in a markdown file that looks like this: ```markdown # My Title My content More content ``` ## `excludeLlmify` In cases where you want to exclude certain content from being converted to markdown, you can use the `excludeLlmify` tag. This is useful for content that should not be part of the markdown generation. ```twig [templates/entry.twig] {% llmify %}

My Title

My content

{% excludeLlmify %}

This content will not be included in the markdown file.

{% endexcludeLlmify %} {% endllmify %} ``` This will result in a markdown file that looks like this: ```markdown # My Title My content ``` ## Exclude by Class If you need even more control about what gets included in the markdown generation, you can use the `exclude-llmify` class on any HTML. If this class conflicts or you have multiple classes that should be excluded, you can configure them in the `llmify.php` config file. ```twig [templates/entry.twig] {% llmify %}

My Title

My content

This content will not be included in the markdown file.

{% endllmify %} ``` This will result in a markdown file that looks like this: ```markdown # My Title My content ``` ## Per-Entry Control The **LLMify Settings Field** is a custom Craft field that you can add to any Entry Type or Product Type. It provides per-entry control over: - **Include/Exclude**: Toggle whether an individual entry is included in Markdown generation, `llms.txt`, and `llms-full.txt`. - **Title Override**: Override the section-level title source with a custom field or static text. - **Description Override**: Override the section-level description source with a custom field or static text. - **Front Matter Override**: Override the inherited front matter fields for this specific entry. To use it, create a new field of type "LLMify Settings" in Settings > Fields, and add it to the field layout of your Entry Type. ## Section Order Sections in `llms.txt` and `llms-full.txt` follow the order you set in **LLMify > Content**. Drag any row by its handle to reorder it. The new order is saved immediately and reflected the next time the files are requested. The order is stored per site. In a multi-site setup you can switch sites via the breadcrumb dropdown and set a different order in each one. New sections (or product types) you add later are appended to the end of the list. ## Twig in Text Fields All plain text fields in Site Settings, Content Settings, and the LLMify Settings Field accept Twig syntax. The same shorthand Craft uses for URI formats and Generated Fields works here: ```twig {name} → object property (e.g. site or section name) {{ object.handle }} → explicit Twig output {{ now|date('Y-m-d') }} → dynamic values ``` The render context depends on where the field lives: | Field location | `object` is | | ----------------------------------------------------- | ------------------------------ | | Site Settings (LLM Title, LLM Description, LLM Note) | the current `Site` | | Content Settings (Section Title, Section Description) | the `Section` or `ProductType` | | Content Settings (Default Title, Default Description) | the rendered `Element` | | LLMify Settings Field (LLM Title, LLM Description) | the rendered `Element` | HTML tags are stripped from the output, so it is safe to reference CKEditor or other rich text fields. If a Twig snippet fails to render (for example a typo in a function name), LLMify falls back to the string and logs a warning to `storage/logs/llmify.log` so the public `llms.txt` never breaks. ## Troubleshooting ### Empty Markdown If your Markdown is generated but empty, it usually means the `{% llmify %}` tags are missing from your template, or they don't wrap any content. Make sure your entry template includes at least one `{% llmify %}` block around the content you want to convert. The sidebar panel in the entry editor will show a warning if Markdown is empty or the entry is deactivated for llmify through settings. # Content Delivery LLMify provides two ways to automatically serve Markdown instead of HTML: 1. **Content Negotiation** (`autoServeMarkdown`): Serves Markdown when a request contains an `Accept: text/markdown` header. 2. **AI Bot Detection** (`enableBotDetection`): Automatically detects known AI crawlers by their user agent and serves Markdown to them. Content Negotiation is enabled by default. AI Bot Detection is disabled by default and has to be enabled deliberately: it forces Markdown on crawlers that did not ask for it, which some search engines consider cloaking. ## Content Negotiation When `autoServeMarkdown` is enabled, LLMify uses content negotiation to serve Markdown instead of HTML. If a request contains an `Accept: text/markdown` header, the same URL that normally returns HTML will return clean Markdown instead. This approach is inspired by [Cloudflare's Markdown for Agents](https://blog.cloudflare.com/markdown-for-agents){rel=""nofollow""}. It allows AI agents to request your existing page URLs and receive structured Markdown without needing separate `/raw/.md` endpoints. This works well in combination with the [`isRealUrlLlm`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#isrealurlllm) setting. When both are enabled, `llms.txt` will point to your real page URLs, and AI agents can request those URLs with the `Accept: text/markdown` header to get the Markdown version. ## AI Bot Detection When `enableBotDetection` is enabled (disabled by default), LLMify detects known AI crawlers by their user agent and automatically serves Markdown to them. The built-in bot list is sourced from the community-maintained [ai-robots-txt/ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt){rel=""nofollow""} project and ships bundled with the plugin. It currently covers 141 crawlers (GPTBot, ClaudeBot, ChatGPT-User, PerplexityBot, Bytespider, CCBot, and many more) and is refreshed on each plugin release. To detect bots that aren't in the bundled list (yet), use the [`additionalBotUserAgents`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#additionalbotuseragents) config option. ## Discovery Tag When [`autoInjectDiscoveryTag`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#autoinjectdiscoverytag) is enabled (default), LLMify injects a `` tag into the HTML head of every page that has Markdown available: ```html ``` This allows AI agents to discover the Markdown version of a page without needing to know about content negotiation. ## How It Works When a request hits a page: 1. LLMify checks if the request has an `Accept: text/markdown` header or if the user agent matches a known AI bot. 2. If either condition is met, it looks up the pre-generated Markdown for the requested entry. 3. If no pre-generated version exists, it generates the Markdown on-the-fly from the template output. 4. The response is returned with the appropriate headers. ### Response Headers All Markdown responses include the following headers: | Header | Value | Purpose | | -------------- | ---------------------------------------------------- | -------------------------------------------------------- | | `Content-Type` | `text/markdown; charset=utf-8` | Identifies the response as Markdown | | `Vary` | `Accept` (or `Accept, User-Agent` for bot detection) | Tells CDNs to cache HTML and Markdown separately | | `X-Robots-Tag` | `noindex, nofollow` | Prevents search engines from indexing Markdown responses | Auto-served Markdown is returned at the page's own URL, so no canonical header is needed — the `Vary` header already signals that HTML and Markdown are representations of the same resource. The dedicated `/raw/.md` URLs do add a `Link: ; rel="canonical"` header, since the Markdown lives at a separate URL there. ## Testing You can test both auto-serve methods with `curl`: ::code-group ```bash [Content negotiation] curl -H "Accept: text/markdown" https://your-site.com/about ``` ```bash [Bot detection] curl -A "GPTBot/1.0" https://your-site.com/about ``` ```bash [Verify headers] curl -v -H "Accept: text/markdown" https://your-site.com/about 2>&1 | grep "< " ``` :: ## Blitz Integration LLMify works with [Blitz](https://putyourlightson.com/plugins/blitz){rel=""nofollow""} out of the box when using the default Blitz caching strategy. LLMify automatically tells Blitz to skip its cache for `Accept: text/markdown` requests, so the request is passed through to PHP where LLMify can handle it. ### Blitz with Server Rewrites If you use Blitz with **Nginx server rewrites**, Nginx serves cached pages directly from the file system without ever hitting PHP. In this case, LLMify cannot intercept the request. To fix this, add a condition to your Nginx config that skips the Blitz cache when the `Accept` header contains `text/markdown`: ```nginx set $cache_path false; if ($request_method = GET) { set $cache_path /cache/blitz/$host/$uri/index.html; } if ($args ~ "token=") { set $cache_path false; } # Skip Blitz cache for text/markdown requests (LLMify auto-serve) if ($http_accept ~ "text/markdown") { set $cache_path false; } location / { try_files $cache_path $uri $uri/ /index.php?$query_string; } ``` # Console Commands As of now the LLMify plugin provides two console commands to manage the markdown generation process. You can run these commands in CI/CD pipelines or as cron jobs to automate the markdown generation process. ## `craft llmify/markdown/generate` This will generate the markdown files for all entries that are enabled for markdown generation and have the `llmify` tag in their templates. ```bash php craft llmify/markdown/generate ``` ## `craft llmify/markdown/clear` This will clear all generated markdown files. This is useful if you want to regenerate the markdown files from scratch. ```bash php craft llmify/markdown/clear ``` ## Cron Jobs You can set up cron jobs to automate the Markdown generation process: ```bash # Generate markdown files for all entries every day at midnight 0 0 * * * php /path/to/craft llmify/markdown/generate ``` # AI Bot Analytics LLMify ships with a built-in integration for [Craft Insights](https://samuelreichor.at/libraries/craft-insights) that turns every Markdown response your site serves into actionable AI bot analytics. When both plugins are installed, Insights gains a dedicated dashboard for tracking AI crawlers. ![AI Bot Analytics dashboard powered by the LLMify and Insights integration](https://samuelreichor.at/images/bitmap/craft-insights-llmify-integration.png) ## What You Get A new **LLM Bots** entry in the Insights subnav with: - **Headline KPIs**: total visits split between bots and humans, unique crawler count, top crawler with traffic share, and the delivery split between direct Markdown URLs and content-negotiated responses. - **Crawl Activity chart**: daily request volume, groupable by total, by bot, or by delivery method. - **Crawlers leaderboard**: per-bot request counts so you can see which AI agents read your site most. - **Top Visited Markdowns**: ranked list of `.md` pages, `llms.txt`, and `llms-full.txt` by visit count. ## Setup There is no configuration. The integration activates automatically when both plugins are installed and enabled. ::steps ### Install Insights Install the [Craft Insights](https://samuelreichor.at/libraries/craft-insights/get-started/installation) plugin alongside LLMify: :::code-group ```bash [ddev] ddev composer require samuelreichor/craft-insights && ddev craft plugin/install insights ``` ```bash [php] composer require samuelreichor/craft-insights && php craft plugin/install insights ``` ::: ### Grant Permission Go to **Settings > Users > User Groups** and grant the **View LLM Bots** permission under the Insights heading. This permission only appears when LLMify is installed. ### Open the Dashboard Navigate to **Insights > LLM Bots** in the Craft control panel. The dashboard starts populating as soon as AI crawlers or users hit any Markdown URL on your site. :: ::alert{variant="note"} For a metric-by-metric breakdown (Total Visits, Unique Bots, Top Crawler, Delivery Split, and more) see the [Metrics Reference](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#llm-bots) on the Insights docs. :: ## Editions Available in both Insights Lite and Insights Pro. The LLM Bots page is gated by its own permission and only exposed when LLMify is installed, so neither plugin is affected by the absence of the other. # Headless When your frontend is rendered by a separate app (Nuxt, Next, Astro, …) instead of Craft, enable [`headlessMode`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#headlessmode). LLMify then generates Markdown by fetching your front-end URLs and exposes it through the API endpoints below, so your frontend can pull the content and re-serve it under its own domain. If the frontend is protected with HTTP Basic Auth, set the [`basicAuthUsername` and `basicAuthPassword`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#basicauthusername-and-basicauthpassword) settings so LLMify can fetch it. ::alert{variant="note"} The auto-serve, bot detection and discovery-tag features rely on Craft rendering the frontend and are disabled in headless mode. Your frontend is responsible for serving `llms.txt`, the `.md` pages and the discovery tag. See the [example implementation](https://samuelreichor.at/#example-implementation). :: ## Authentication The API is only available while [`headlessMode`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#headlessmode) is enabled, otherwise every endpoint returns `403`. Protection with a token is optional: - **No token configured** (default): the endpoints are reachable without authentication. The content they expose (`llms.txt`, page Markdown) is public anyway. - **Token configured** via the [`apiToken`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#apitoken) setting: every request must send it in the `X-Llmify-Token` header. Requests with a missing or wrong token return `403`. ```bash curl -H "X-Llmify-Token: " \ "https://cms.example.com/actions/llmify/api/llms-txt" ``` ## `llms.txt` Returns the `llms.txt` content for a site. ```http GET /actions/llmify/api/llms-txt?site= ``` | Parameter | Required | Description | | --------- | -------- | ------------------------------------------------ | | `site` | No | Site handle or id. Defaults to the primary site. | **Responses** | Status | Meaning | | ------ | --------------------------------- | | `200` | `text/markdown` body | | `400` | Unknown `site` | | `404` | No content available for the site | ```bash curl "https://cms.example.com/actions/llmify/api/llms-txt?site=default" ``` ## `llms-full.txt` Returns the `llms-full.txt` content for a site. Identical interface to [`llms.txt`](https://samuelreichor.at/#llmstxt). ```http GET /actions/llmify/api/llms-full-txt?site= ``` ```bash curl "https://cms.example.com/actions/llmify/api/llms-full-txt?site=default" ``` ## `page` Returns the pre-generated Markdown for a single page, identified by its URI. The response includes front matter, and only pages whose section is enabled are served. This is the recommended way to serve individual `.md` files, as it reads the stored Markdown without re-converting. ```http GET /actions/llmify/api/page?uri=&site= ``` | Parameter | Required | Description | | --------- | -------- | ------------------------------------------------------------------------------------------------- | | `uri` | Yes | The Craft URI of the page, e.g. `blog`, `news/my-post`. The home page is stored under `__home__`. | | `site` | No | Site handle or id. Defaults to the primary site. | **Responses** | Status | Meaning | | ------ | ---------------------------------------- | | `200` | `text/markdown` body (with front matter) | | `400` | Missing `uri` or unknown `site` | | `404` | No Markdown stored for the URI | ```bash curl "https://cms.example.com/actions/llmify/api/page?uri=blog&site=default" ``` ## `convert` Fetches a front-end URL and returns its converted Markdown on the fly, **without** persisting it or adding front matter. Use this for ad-hoc conversion; prefer [`page`](https://samuelreichor.at/#page) for serving stored pages. ```http POST /actions/llmify/api/convert Content-Type: application/json { "url": "https://www.example.com/blog/my-post" } ``` | Parameter | Required | Description | | --------- | -------- | --------------------------------------- | | `url` | Yes | The front-end URL to fetch and convert. | ::alert{variant="caution"} The target `url` must resolve to one of your configured site Base URL hosts (SSRF guard). Other hosts return `403`. :: **Responses** | Status | Meaning | | ------ | -------------------------------------- | | `200` | `text/markdown` body | | `400` | Missing or invalid `url` | | `403` | `url` host is not an allowed site host | | `404` | URL could not be fetched | | `405` | Method other than `POST` | ```bash curl -X POST "https://cms.example.com/actions/llmify/api/convert" \ -H "Content-Type: application/json" \ -d '{"url":"https://www.example.com/blog/my-post"}' ``` ## Example Implementation The [llmify-headless-showcase](https://github.com/samuelreichor/llmify-headless-showcase/tree/main/frontend/server){rel=""nofollow""} repository shows a complete Nuxt integration: server routes that proxy `llms.txt` / `llms-full.txt`, serve individual `.md` pages via the `page` endpoint, and inject the discovery `` tag. # WebMCP [WebMCP](https://github.com/webmachinelearning/webmcp){rel=""nofollow""} is an experimental browser standard that lets websites offer tools to AI agents running inside the visitor's browser (e.g. Gemini in Chrome). Instead of scraping the rendered page, the agent calls the tools your site provides and gets structured answers back. With WebMCP enabled, LLMify registers a set of read-only tools on every front-end page. A visitor's browser agent can then search your content, read whole pages as Markdown, and navigate the site on the visitor's behalf. ## What You Get Every front-end page loads a small script that registers four tools with the browser agent: - **Search content**: full-text search across your enabled content, returning matching pages with title, description, and URL. - **Get page**: the full Markdown of a single page, the same content your `.md` URLs serve. - **List sections**: the content sections available on the current site. - **Navigate**: sends the visitor's browser to a page on your site (same-origin only). The tools respect your LLMify configuration: only content from enabled sections shows up, excluded entries never appear, and everything is read-only. Agents can only reach content that is already public through your Markdown URLs, and all responses are marked `noindex`. ## Enable WebMCP WebMCP is disabled by default. Turn it on in the control panel under **Settings > LLMify > WebMCP**, or via the [`enableWebMcp`](https://samuelreichor.at/libraries/craft-llmify/get-started/config#enablewebmcp) config setting: ```php [ 'enableWebMcp' => true, ], ]; ``` ::alert{variant="caution"} WebMCP is an experimental, pre-standard browser API and currently Chrome-only. Enabling it publishes a public, machine-callable search and content API over your already-public content. :: To verify it works, open any front-end page and check that `/webmcp.js` loads. Browsers without WebMCP support ignore the script entirely. ::alert{variant="note"} WebMCP targets sites where Craft renders the front end. In [headless mode](https://samuelreichor.at/libraries/craft-llmify/usage/headless) the script is not injected automatically. :: ## Custom Tools This section is for plugin and module developers. LLMify fires `WebMcpService::EVENT_REGISTER_TOOLS` when the tool set is built, so you can add your own tools, adjust the built-in definitions, or remove tools entirely. Appending a tool with the name of an existing one replaces it. A tool consists of a name, a description for the agent, a JSON Schema for its input, and a client-side JavaScript handler. The handler receives the tool input and a `helpers` object with `text(value)` to build a text result, `fetchTool(url)` for same-origin JSON requests, and `config`. ```php use samuelreichor\llmify\events\RegisterWebMcpToolsEvent; use samuelreichor\llmify\services\WebMcpService; use yii\base\Event; Event::on( WebMcpService::class, WebMcpService::EVENT_REGISTER_TOOLS, function(RegisterWebMcpToolsEvent $event) { $event->tools[] = [ 'name' => 'get_opening_hours', 'description' => 'Get the opening hours of the store.', 'inputSchema' => ['type' => 'object'], 'handler' => << { const r = await helpers.fetchTool(new URL('/api/opening-hours', location.origin)); return helpers.text(r.ok ? JSON.stringify(r.data) : 'Opening hours are unavailable.'); } JS, ]; } ); ``` The handler runs in the visitor's browser. Keep it self-contained, return a result via `helpers.text()` instead of throwing, and only call endpoints that are safe to expose publicly. # Twig Functions LLMify registers three Twig functions for your front-end templates. Use them to build "View as Markdown" links or "Open in AI" buttons that hand the current page to ChatGPT or Claude. All three functions accept an optional element. Without an argument they use the element of the currently rendered page. They return `null` when no Markdown is available for the element (no URI, excluded from LLMify, or its section is disabled), so always wrap the output in a null check. ## `mdUrl()` Returns the Markdown URL of an element, e.g. `https://example.com/raw/news/my-article.md`. ```twig {% set url = mdUrl() %} {% if url %} View as Markdown {% endif %} ``` Pass an element explicitly to link to other pages, for example in an overview list: ```twig {% for item in entries %} {% set itemUrl = mdUrl(item) %} {% if itemUrl %} {{ item.title }} as Markdown {% endif %} {% endfor %} ``` ## `chatGptUrl()` Returns a link that opens ChatGPT with a prompt asking it to read the page's Markdown, so the visitor can chat about your content right away. ```twig {% set url = chatGptUrl() %} {% if url %} Ask ChatGPT about this page {% endif %} ``` ## `claudeUrl()` Same as `chatGptUrl()`, but opens the prompt in Claude. ```twig {% set url = claudeUrl() %} {% if url %} Ask Claude about this page {% endif %} ``` ::alert{variant="note"} The AI chat links point ChatGPT and Claude at the page's public Markdown URL. They only work on production domains the AI services can reach, not on local development URLs. :: # LLMify AI models like ChatGPT struggle to read websites built for people. They see a wall of code, menus, ads, and sidebars. This "noise" makes it hard for them to find the real story - your valuable story. LLMify solves this by converting your Twig templates into clean, structured Markdown. ::alert If you want to learn more about the broader landscape of Generative Engine Optimization, check out my blog post: [Current State of GEO](https://samuelreichor.at/blogs/current-geo-state). :: ## Why LLMify? LLMify is built for production-scale AI content delivery. Instead of converting HTML to Markdown on every request, LLMify does the work upfront. Your Markdown is stored and ready before any bot shows up. Combined with Craft Commerce compatibility, granular control over your Markdowns and user permissions, LLMify gives you everything you need to make your entire site AI-ready. ## Features ### Content Generation - **Pre-Generated Markdown**: Async batch processing stores Markdown in a dedicated database table for instant delivery at any scale. - **On-Demand Fallback**: Automatically generates Markdown on first request if not yet pre-generated. - **Template-Level Control**: Use `{% llmify %}` and `{% excludeLlmify %}` Twig tags for precise control over what content is included. - **CSS Class Exclusion**: Define classes to exclude entire sections from the HTML-to-Markdown conversion. - **YAML Front Matter**: Configurable metadata with hierarchical inheritance (Site > Section > Entry). - **Console Commands**: `llmify/markdown/generate` and `llmify/markdown/clear` for CI/CD and deployment workflows. ### AI Content Delivery - **Auto-Serve Markdown**: Content negotiation via `Accept: text/markdown` header. - **AI Crawler Detection**: Automatically serve Markdown to known AI bots (GPTBot, ClaudeBot, ChatGPT-User, and more). - **LLM-Ready Text Files**: Generates `llms.txt`, `llms-full.txt`, and `/.well-known/llms.txt`. - **Discovery Tag**: Injects `` into your HTML head. - **[WebMCP Tools](https://samuelreichor.at/libraries/craft-llmify/usage/webmcp)**: Opt-in support for the experimental WebMCP standard, giving in-browser AI agents (e.g. Gemini in Chrome) read-only search, page, and navigation tools over your enabled content. - **[Twig Functions](https://samuelreichor.at/libraries/craft-llmify/usage/twig-functions)**: `mdUrl()`, `chatGptUrl()`, and `claudeUrl()` for "View as Markdown" links and buttons that open the current page in ChatGPT or Claude. - **Industry Standard Response Headers**: Sets `Vary: Accept`, `X-Robots-Tag: noindex, nofollow`, and `Link: rel="canonical"` on all Markdown responses. ### Headless - **[Headless Support](https://samuelreichor.at/libraries/craft-llmify/usage/headless)**: Running a separate front end (Nuxt, Next, Astro, …)? LLMify generates Markdown by fetching your front-end URLs and exposes API endpoints to serve `llms.txt`, `llms-full.txt`, and individual `.md` pages from your own domain. ### Content Management - **Hierarchical Settings**: Site-wide, section, and entry-level configuration with inheritance. - **Per-Entry Control**: Include or exclude individual entries via the LLMify Settings Field. - **Permission System**: Granular user permissions for dashboard, content settings, site settings, generate, and clear actions. - **Preview Targets**: Preview Markdown output directly from the entry editor. - **Dashboard**: Site setup scores and section-level content statistics at a glance. ### Integrations - **SEOmatic Integration**: Automatically populate front matter from SEOmatic fields. - **Craft Commerce Support**: Full support for Commerce Products alongside Entries. - **[AI Bot Analytics](https://samuelreichor.at/libraries/craft-llmify/usage/ai-bot-analytics)**: Install [Craft Insights](https://samuelreichor.at/libraries/craft-insights) to get a dedicated dashboard for AI bot traffic, crawler breakdowns, top visited Markdowns, and delivery splits across every Markdown response your site serves. ## Why it Matters ### Future-Proof Your Site The way we find information is changing. Search is evolving from a list of links into a direct conversation with AI. By making your content perfectly readable for machines, you ensure your website remains a trusted, primary source for these new systems. You're not just optimizing for today; you're securing your relevance for tomorrow's web. ### Improve AI Accuracy AI models are powerful, but they're only as good as the data they read. When they parse messy HTML, they have to guess what's important, often leading to incorrect summaries and unreliable answers. By feeding them clean, structured content, you eliminate the guesswork. This ensures that when an AI references your site, it represents your brand and information with the accuracy you can trust. ### Gain a Competitive Edge Right now, most websites are only built for human eyes. This gives you a massive opportunity. By making your site "bilingual"—fluent in both human-centric design and machine-readable text—you put yourself far ahead of the competition. While their content is ignored or misinterpreted by AI, yours will be the clear, authoritative source, capturing a rapidly growing channel of traffic and influence. # Installation ## Requirements - Supports Craft CMS > 5 - PHP 8.2 or later. ## Craft Plugin Store To install Genesis, go to the Plugin Store in your Craft control panel, search for "Genesis," and click the Install button. ## Composer ::code-group ```bash [ddev] ddev composer require samuelreichor/craft-genesis && ddev craft plugin/install genesis ``` ```bash [php] composer require samuelreichor/craft-genesis && php craft plugin/install genesis ``` :: Or install it from the Craft Plugin Store in your control panel. ## Next Steps After installation, navigate to **Utilities > Genesis Import** in your control panel to start importing elements. ## Example Config File To get started quickly, use this [example config file](https://samuelreichor.at/other-files/example-craft-config.xlsx){download="/other-files/example-craft-config.xlsx"} and get begin to configure it. ::alert{variant="note"} You can use Title Case or camelCase for the column header. :: # Sites Import ![Preview of the excel configuration of sites](https://samuelreichor.at/images/bitmap/genesis-excel-sites.png) ## Columns | Column | Required | Description | | ---------- | -------- | ----------------------------------------- | | `handle` | Yes | Unique identifier for the site | | `name` | Yes | Display name | | `language` | Yes | Language code (e.g., `en`, `de`, `de-AT`) | | `baseUrl` | No | Site URL, supports aliases like `@web` | | `primary` | No | Set as primary site (`true`/`false`) | | `hasUrls` | No | Site has public URLs (`true`/`false`) | | `enabled` | No | Site is enabled (`true`/`false`) | | `group` | No | Site group Label | ## Example ```csv handle,name,language,baseUrl,primary,hasUrls,enabled,group en,English,en,$PRIMARY_URL_EN,TRUE,TRUE,TRUE,Default de,Deutsch,de,$PRIMARY_URL_DE,FALSE,TRUE,TRUE,Default ``` ## Language Codes Use valid BCP 47 language tags: - `en` - English - `de` - German - `de-AT` - Austrian German - `zh-Hans` - Simplified Chinese - `pt-BR` - Brazilian Portuguese ## Boolean Values These values are accepted for boolean fields: - **True**: `true`, `TRUE`, `1`, `yes`, `on` - **False**: `false`, `FALSE`, `0`, `no`, `off` # Entry Types Import ![Preview of the excel configuration of entry types](https://samuelreichor.at/images/bitmap/genesis-excel-entryTypes.png) ## Columns | Column | Required | Description | | --------------------------- | -------- | ---------------------------------- | | `handle` | Yes | Unique identifier | | `name` | Yes | Display name | | `description` | No | Description text | | `titleTranslationMethod` | No | How titles are translated | | `titleTranslationKeyFormat` | No | Custom translation key format | | `showSlug` | No | Show slug field (`true`/`false`) | | `slugTranslationMethod` | No | How slugs are translated | | `slugTranslationKeyFormat` | No | Custom slug translation key format | | `showStatusField` | No | Show status field (`true`/`false`) | ## Example ```csv handle,name,description,titleTranslationMethod,titleTranslationKeyFormat,showSlug,slugTranslationMethod,slugTranslationKeyFormat,showStatusField default_pagebuilder,Default Pagebuilder,Build pages with this entry type,Translate for each language,,TRUE,Not translatable,,TRUE default_contentbuilder,Default Contentbuilder,Build parts of pages with this entry type,Custom…,{{include('global/site.twig')}},FALSE,Not translatable,,TRUE ``` ## Translation Methods | Value | Label | Description | | ----------- | ----------------------------- | ------------------------------ | | `none` | Not translatable | Same value across all sites | | `site` | Translate for each site | Different value per site | | `siteGroup` | Translate for each site group | Different value per site group | | `language` | Translate for each language | Different value per language | | `custom` | Custom... | Use custom key format | When using `custom`, you must provide the corresponding `KeyFormat` column. ## Custom Translation Example ```csv handle,name,titleTranslationMethod,titleTranslationKeyFormat shared,Shared Content,custom,{section.handle} ``` # Sections Import ![Preview of the excel configuration of sections](https://samuelreichor.at/images/bitmap/genesis-excel-sections.png) ## Columns | Column | Required | Description | | -------------------------- | -------- | ---------------------------------------------- | | `handle` | Yes | Unique identifier | | `name` | Yes | Display name | | `type` | Yes | Section type: `single`, `channel`, `structure` | | `entryTypes` | Yes | Comma-separated entry type handles | | `enableVersioning` | No | If versioning should be enabled | | `site` | No | Site handle for site-specific settings | | `siteUri` | No | URI pattern (e.g., `blog/{slug}`) | | `siteTemplate` | No | Template path | | `siteHome` | No | Is homepage (`true`/`false`, singles only) | | `siteDefaultStatus` | No | Default status: `true`/`false` | | `propagationMethod` | No | How entries propagate across sites | | `maxAuthors` | No | Maximum number of authors | | `maxLevels` | No | Maximum nesting levels (structures only) | | `defaultPlacement` | No | New entry placement (structures only) | | `enablePreviewTargets` | No | If preview targets exist | | `previewTargetLabel` | No | Preview Target label | | `previewTargetUrlFormat` | No | Preview Target url format | | `previewTargetAutoRefresh` | No | If Preview Target should auto refresh | ## Example ```csv handle,name,type,site,siteUri,siteTemplate,siteHome,siteDefaultStatus,entryTypes,propagationMethod,maxAuthors,maxLevels,defaultPlacement blog,Blog,channel,en,{slug},/pages/_entry.twig,,TRUE,"default_pagebuilder, default_contentbuilder",Only save entries to the site they were created in,2,, blog,,,de,{slug},/pages/_entry.twig,,FALSE,,,,, home,Home,single,en,,/pages/_entry.twig,TRUE,TRUE,default_pagebuilder,Save entries to other sites in the same site group,,, pages,Pages,structure,en,{slug},/pages/_entry.twig,,TRUE,default_contentbuilder,Save entries to other sites with the same language,10,4,Before other entries ``` ## Section Types | Type | Description | | ----------- | ---------------------------------------------------- | | `single` | One entry only, typically for homepage or about page | | `channel` | Multiple entries in chronological order | | `structure` | Hierarchical entries with parent-child relationships | ## Propagation Methods | Value | Label | Description | | ----------- | -------------------------------- | ----------------------------------- | | `all` | Save to all sites | Entry exists in all sites | | `siteGroup` | Save to site group | Entry exists in same site group | | `language` | Save to sites with same language | Entry exists in same-language sites | | `none` | Only save to this site | Entry is site-specific | | `custom` | Custom... | Use custom propagation rules | ## Default Placement For structures you can use the `defaultPlacement`. | Value | Label | Description | | ----------- | -------------------- | ---------------------------------- | | `beginning` | Before other entries | Place new entries at the beginning | | `end` | After other entries | Place new entries at the end | ## Multiple Entry Types Separate multiple entry type handles with commas: ```csv handle,name,type,entryTypes news,News,channel,"article, event,announcement" ``` Use quotes when the value contains commas. ## Multiple Site Settings For multiple site settings, use different rows with the same section handle. ## Multiple Preview Targets For multiple preview targets, use different rows with the same section handle. # Filesystems Import ![Preview of the excel configuration of filesystems](https://samuelreichor.at/images/bitmap/genesis-excel-filesystems.png) ## Columns | Column | Required | Description | | ------------ | -------- | ------------------------------------------------- | | `handle` | Yes | Unique identifier | | `name` | Yes | Display name | | `basePath` | Yes | Storage path | | `publicUrls` | No | Has public URLs (`true`/`false`) | | `baseUrl` | No | Public URL (required when `publicUrls` is `true`) | ## Example ```csv handle,name,publicUrls,baseUrl,basePath images,Images,FALSE,,@webroot/assets/images graphics,Graphics,TRUE,@web/assets/graphics,@webroot/assets/graphics ``` # Assets Import ![Preview of the excel configuration of assets](https://samuelreichor.at/images/bitmap/genesis-excel-assets.png) ## Columns | Column | Required | Description | | --------------------------- | -------- | -------------------------------------------- | | `handle` | Yes | Unique identifier | | `name` | Yes | Display name | | `fsHandle` | Yes | Filesystem handle (must exist) | | `subpath` | No | Subpath within filesystem | | `transformFsHandle` | No | Filesystem for image transforms (must exist) | | `transformSubpath` | No | Subpath for transforms | | `titleTranslationMethod` | No | How titles are translated | | `titleTranslationKeyFormat` | No | Custom title translation key | | `altTranslationMethod` | No | How alt text is translated | | `altTranslationKeyFormat` | No | Custom alt translation key | ## Example ```csv handle,name,fsHandle,subpath,transformFsHandle,transformSubpath,titleTranslationMethod,titleTranslationKeyFormat,altTranslationMethod,altTranslationKeyFormat images,Images,images,images,images,/images/transforms,Translate for each site,,Custom…,{{include('global/site.twig')}} graphics,Graphics,graphics,,,,Custom…,{{include('global/site.twig')}},, ``` ## Prerequisites The filesystem referenced by `fsHandle` must exist before importing asset volumes. Import filesystems first, then assets. ## Transform Filesystem Use a separate filesystem for image transforms to keep originals and transforms organized: ```csv handle,name,fsHandle,subpath,transformFsHandle,transformSubpath images,Images,assets,images,assets,_transforms/images ``` ## Translation Methods Same as entry types: | Value | Description | | ----------- | ------------------------ | | `none` | Not translatable | | `site` | Translate per site | | `siteGroup` | Translate per site group | | `language` | Translate per language | | `custom` | Use custom key format | # Genesis ## What Can You Import? - **Sites** - Multi-site setup with language and URL configuration - **Entry Types** - Define content types with translation settings - **Sections** - Channels, structures, and singles with URI patterns - **Filesystems** - Local storage paths for assets - **Asset Volumes** - Configure where your assets live ![Genesis utility](https://samuelreichor.at/images/bitmap/genesis-utility.png) ## How It Works 1. **Download the CSV template** or create a google / excel sheet. 2. **Fill in your data** 3. **Upload and validate** to catch errors before importing 4. **Import**, Genesis handles the rest via queue jobs ## Validation Genesis validates your CSV before importing: - Checks for required columns - Validates column names against allowed fields - Verifies data types (booleans, language codes, etc.) - Ensures referenced elements exist (sites, entry types, filesystems) ![Validtion errors after uploading invalid csv file](https://samuelreichor.at/images/bitmap/genesis-validation.png) This catches mistakes early so you don't end up with partial imports. ## Example Config File To get started quickly, use this [example config file](https://samuelreichor.at/other-files/example-craft-config.xlsx){download="/other-files/example-craft-config.xlsx"} and get begin to configure it. # Installation ## Requirements - Craft CMS 5.0.0 or later - PHP 8.2 or later ## Craft Plugin Store To install Insights, navigate to the Plugin Store in your Craft control panel, search for "Insights," and click **Install**. ## Composer ::code-group ```bash [ddev] ddev composer require samuelreichor/craft-insights && ddev craft plugin/install insights ``` ```bash [php] composer require samuelreichor/craft-insights && php craft plugin/install insights ``` :: ## Add the Tracking Script After installation, add the tracking script to your base template (usually `_layouts/base.twig` or similar): ```twig {{ craft.insights.trackingScript() }} ``` ::alert{variant="note"} Technically it's not important where you add the script on your page as it always injects the script at the end of the body. :: That's it! Insights will now track pageviews automatically. ## GeoIP Database To enable country tracking, you need to download the free MaxMind GeoLite2-Country database: 1. Create a free account at [MaxMind](https://www.maxmind.com/en/geolite2/signup){rel=""nofollow""} 2. Download the **GeoLite2 Country** database (`.mmdb` format) 3. Place the file at `storage/geoip/GeoLite2-Country.mmdb` Or configure a custom path in your settings: ```php // config/insights.php return [ 'geoIpDatabasePath' => '@storage/geoip/GeoLite2-Country.mmdb', ]; ``` ::alert{variant="note"} Country data is collected for all editions. Lite users who upgrade to Pro will have historical country data available. The country tracking dashboard is a Pro feature, but setting up the GeoIP database early ensures you have data when you upgrade. Without the database, country tracking is silently skipped. :: # Configuration ## Control Panel You can manage configuration settings through the Control Panel by visiting Settings → Insights. ## Settings You can define a multi environment aware config in `/config/insights.php`. Settings defined in the config file override control panel settings. ### `enabled` Enable or disable all tracking globally. ```php return [ '*' => [ 'enabled' => true, ], 'staging' => [ 'enabled' => false, // disable tracking on staging ], ] ``` ### `respectDoNotTrack` Honor the browser's Do Not Track (DNT) header. When enabled, requests with `DNT: 1` header are not tracked. ```php return [ '*' => [ 'respectDoNotTrack' => true, // default ], ] ``` ### `excludeLoggedInUsers` Skip tracking for authenticated Craft users. Useful to exclude your own team from analytics. ```php return [ '*' => [ 'excludeLoggedInUsers' => false, // default ], 'production' => [ 'excludeLoggedInUsers' => true, // don't track logged in users in production ], ] ``` ### `excludedIpRanges` :badge{label="Pro"} IP addresses or CIDR ranges to exclude from tracking. ```php return [ '*' => [ 'excludedIpRanges' => [ '192.168.1.1', // single IP '10.0.0.0/8', // CIDR range '172.16.0.0/12', // private network ], ], ] ``` ### `excludedPaths` URL paths to exclude from tracking. Uses prefix matching. ```php return [ '*' => [ 'excludedPaths' => [ '/admin', // matches /admin, /admin/*, etc. '/cpresources', '/actions', '/api', // exclude API endpoints '/preview', // exclude preview pages ], ], ] ``` ### `geoIpDatabasePath` Path to the MaxMind GeoLite2-Country database for country tracking. ```php return [ '*' => [ 'geoIpDatabasePath' => '@storage/geoip/GeoLite2-Country.mmdb', // default ], ] ``` ::alert{variant="note"} Country data is collected for all editions. Lite users who upgrade to Pro will have historical data available. Without the GeoIP database, country tracking will be silently skipped. See [Installation](https://samuelreichor.at/libraries/craft-insights/get-started/installation#geoip-database) for setup instructions. :: ### `dataRetentionDays` Number of days to retain analytics data. Valid range: 1-730 days. ```php return [ '*' => [ 'dataRetentionDays' => 365, // default: keep data for 1 year ], ] ``` ### `autoCleanup` Automatically delete data older than `dataRetentionDays`. Cleanup runs daily via Craft's garbage collection. ```php return [ '*' => [ 'autoCleanup' => true, // default ], ] ``` ::alert{variant="warning"} Cleanup is irreversible. Old data is permanently deleted. :: ### `useQueue` Process tracking events asynchronously via Craft's queue system. Recommended for production to minimize page load impact. ```php return [ '*' => [ 'useQueue' => true, // default ], 'dev' => [ 'useQueue' => false, // process synchronously in development ], ] ``` ### `queueJobTtr` Time to reserve (TTR) for queue jobs in seconds. This is the maximum time a job can run before being considered stalled. Valid range: 60-3600 seconds. ```php return [ '*' => [ 'queueJobTtr' => 300, // default: 5 minutes ], ] ``` ### `processTrackingJobPriority` Priority for tracking queue jobs. Lower number = higher priority. Craft's default priority is 1024. ```php return [ '*' => [ 'processTrackingJobPriority' => 20, // default ], ] ``` ### `maxRetryAttempts` Maximum number of retry attempts for failed queue jobs. Valid range: 0-10. ```php return [ '*' => [ 'maxRetryAttempts' => 3, // default ], ] ``` ### `realtimeTtl` How long (in seconds) a visitor is considered "active" for real-time tracking. Valid range: 60-900 seconds. ```php return [ '*' => [ 'realtimeTtl' => 300, // default: 5 minutes ], ] ``` ### `defaultDateRange` Default date range for the analytics dashboard. Available values: `today`, `7d`, `30d`, `90d`, `12m`. ```php return [ '*' => [ 'defaultDateRange' => '30d', // default ], ] ``` ### `showRealtimeWidget` Show the real-time visitors widget on the dashboard. ```php return [ '*' => [ 'showRealtimeWidget' => true, // default ], ] ``` ### `showEntrySidebar` Display analytics statistics in the entry editor sidebar. ```php return [ '*' => [ 'showEntrySidebar' => true, // default ], ] ``` ### `logLevel` Control the verbosity of plugin logging. Available values: `default`, `debug`. ```php return [ '*' => [ 'logLevel' => 'default', ], 'dev' => [ 'logLevel' => 'debug', // more detailed logs in development ], ] ``` ### `emailFrequency` How often [scheduled email reports](https://samuelreichor.at/libraries/craft-insights/usage/email-reports) are delivered to recipients. Available values: `never`, `weekly`, `biweekly`, `monthly`. The default `never` disables scheduled sends. ```php return [ '*' => [ 'emailFrequency' => 'weekly', ], ] ``` ### `emailRecipients` Email addresses that should receive scheduled reports. Each recipient gets the same report. ```php return [ '*' => [ 'emailRecipients' => [ 'team@example.com', 'client@example.com', ], ], ] ``` ### `attachPdfReport` Attach the full dashboard PDF report to every scheduled email (and to the test mail). When disabled, only the HTML summary is sent. ```php return [ '*' => [ 'attachPdfReport' => true, // default ], ] ``` ::alert{variant="note"} The attached PDF mirrors the dashboard PDF export - summary KPIs, traffic chart, and every top-list available to your edition. The report period matches the email frequency. :: ### `useCronForEmails` Turn off the automatic scheduling check and send [email reports](https://samuelreichor.at/libraries/craft-insights/usage/email-reports) only through the `insights/notifications/send` console command. Recommended if you want fixed delivery times or your site gets little traffic. ```php return [ '*' => [ 'useCronForEmails' => false, // default ], ] ``` ::alert{variant="note"} When enabled, set up a cron job like `0 8 * * * php /path/to/craft insights/notifications/send`. The command uses the same due-check as the automatic scheduler. A daily cron still sends only once per configured email frequency. :: ## Custom Queue By default, Insights uses Craft's main queue. For high-traffic sites, you can configure a separate queue to prevent analytics jobs from blocking other important jobs. Add the `insightsQueue` component to your `config/app.php`: ```php ['insightsQueue'], 'components' => [ 'insightsQueue' => [ 'class' => \craft\queue\Queue::class, ], ], ]; ``` Then run the custom queue separately: ```bash # Run once php craft insights-queue/run # Run as daemon php craft insights-queue/listen --verbose ``` ::alert{variant="note"} If `insightsQueue` is not configured, Insights automatically falls back to Craft's default queue. No additional configuration required. :: ## External Database :badge{label="Pro"} Store analytics data in a separate database to keep your main Craft database lean. This is useful for high-traffic sites or when you want to isolate analytics data. ### Configuration 1. Add the `insightsDb` component to your `config/app.php`: ```php [ 'insightsDb' => [ 'class' => \craft\db\Connection::class, 'dsn' => App::env('INSIGHTS_DB_DSN'), 'username' => App::env('INSIGHTS_DB_USER'), 'password' => App::env('INSIGHTS_DB_PASSWORD'), 'tablePrefix' => App::env('INSIGHTS_DB_TABLE_PREFIX') ?: '', ], ], ]; ``` 2. Add environment variables to your `.env`: ```bash INSIGHTS_DB_DSN="mysql:host=localhost;port=3306;dbname=insights" INSIGHTS_DB_USER="insights_user" INSIGHTS_DB_PASSWORD="secret" INSIGHTS_DB_TABLE_PREFIX="" ``` 3. Enable external database in `config/insights.php`: ```php return [ '*' => [ 'useExternalDatabase' => true, ], ] ``` ### Database Commands ```bash # Test connection php craft insights/database/test # Show connection status php craft insights/database/status # Create tables in external database php craft insights/database/migrate # Migrate existing data from Craft DB to external DB php craft insights/database/migrate-data php craft insights/database/migrate-data --force # Clear target first php craft insights/database/migrate-data --delete-source # Remove from Craft DB after ``` ::alert{variant="warning"} External database is a Pro feature. The `insightsDb` component must be configured before enabling `useExternalDatabase`. :: ## Multi-Environment Example A complete example showing environment-specific configuration: ```php [ 'enabled' => true, 'respectDoNotTrack' => true, 'excludeLoggedInUsers' => false, 'excludedPaths' => ['/admin', '/cpresources', '/actions'], 'dataRetentionDays' => 365, 'autoCleanup' => true, 'useQueue' => true, 'defaultDateRange' => '30d', ], 'dev' => [ 'useQueue' => false, 'logLevel' => 'debug', ], 'staging' => [ 'enabled' => App::env('INSIGHTS_ENABLED') ?? false, ], 'production' => [ 'excludeLoggedInUsers' => true, ], ]; ``` # Overview Insights is a privacy-first analytics plugin for Craft CMS. It tracks pageviews, referrers and devices without storing personal data or requiring cookie consent banners. ## What It's Not Insights is intentionally limited compared to full-featured analytics platforms. It's designed for content-focused websites that need basic traffic insights without complexity. **Insights is NOT:** - A Google Analytics replacement for marketing teams - An A/B testing or conversion optimization platform - A heat mapping or session recording tool - A real-time user journey tracker - An advertising or retargeting platform **Insights IS:** - A simple way to see what content performs well - A privacy-respecting alternative to invasive trackers - A tool that works without cookie banners - A lightweight script that won't slow down your site ## Who Should Use Insights? Insights is ideal for: - **Content websites** that want basic traffic metrics - **Agency clients** who need simple, understandable stats - **GDPR-conscious sites** that want to avoid cookie consent issues - **Performance-focused developers** who want minimal tracking overhead Consider a different tool if you need: - New vs returning visitor tracking - User-level tracking and segmentation - E-commerce conversion funnels - Marketing attribution modeling - Integration with advertising platforms ## Feature Comparison | Feature | Insights | Google Analytics | Matomo | Plausible | | ------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------- | ------------ | ---------- | | **Privacy** | | | | | | Cookie-free | Yes | No | Optional | Yes | | No fingerprinting | Yes | No | Optional | Yes | | IP anonymization | Discarded | Anonymized | Configurable | Discarded | | GDPR consent required | No | Yes | Depends | No | | **Data** | | | | | | [Pageviews](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#pageviews) | Yes | Yes | Yes | Yes | | [Unique visitors](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#unique-visitors) | Daily hash | Cookies | Cookies | Daily hash | | [Referrers](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#referrers) | Yes | Yes | Yes | Yes | | [UTM campaigns](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#campaign-tracking) | Pro | Yes | Yes | Yes | | [Device/browser](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#devices) | Yes | Yes | Yes | Yes | | [Country](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#countries) | Pro | Yes | Yes | Yes | | [User events](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#user-events) | Pro | Yes | Yes | Yes | | [Scroll depth](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#scroll-depth) | Pro | Yes | Plugin | Yes | | [Entry/exit pages](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#session-insights) | Pro | Yes | Yes | Yes | | [Outbound links](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#outbound-links) | Pro | Yes | Plugin | Yes | | [Site search](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#site-searches) | Pro | Yes | Yes | No | | Returning visitors | No | Yes | Yes | No | | User flows | No | Yes | Yes | No | | **Hosting** | | | | | | Self-hosted | Yes | No | Yes | Optional | | Data location | Your server | Google | Your server | EU/US | | **Integration** | | | | | | Craft CMS Widgets | Yes | No | No | No | | Entry sidebar stats | Yes | No | No | No | | Twig API | Yes | No | No | No | ## How Data Is Stored Unlike traditional analytics that store individual events, Insights aggregates data immediately: ```text Pageview → UPDATE pageviews SET views = views + 1 WHERE url = '/about' ``` This means: - **Smaller database** - No individual event logs - **Faster queries** - Pre-aggregated data - **Better privacy** - No way to reconstruct user sessions - **GDPR compliant** - No personal data stored ## Script Size The tracking script is approximately 3KB (gzipped) and loads asynchronously. It has zero dependencies and won't block page rendering. For comparison: | Script | Size (gzipped) | | ---------------- | -------------- | | Insights | \~3 KB | | Google Analytics | \~45 KB | | Matomo | \~22 KB | | Plausible | \~1 KB | # Feature Tour A visual walkthrough of everything Insights offers - from basic traffic metrics to advanced marketing analytics. ## Dashboard Everything you need, one screen. No clutter, no complexity. ![Insights Dashboard Overview](https://samuelreichor.at/images/bitmap/craft-insights-dashboard.png) Your key metrics front and center: [Pageviews](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#pageviews), [Unique Visitors](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#unique-visitors), [Bounce Rate](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#bounce-rate), [Time on Page](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#avg-time-on-page), and [Real-time Visitors](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#real-time-visitors). Trend indicators show growth or decline compared to the previous period. --- ## Know Your Content See which pages resonate with your audience. ![Pages View Table](https://samuelreichor.at/images/bitmap/craft-insights-pages.png) --- ## Track Traffic Sources Understand where your visitors come from - automatically classified into Direct, Search, Social, and Referral. ![Traffic Sources](https://samuelreichor.at/images/bitmap/craft-insights-referrers.png) [How referrers are classified →](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#referrers) --- ## Device Insights Know your audience: Desktop, Mobile, or Tablet. Optimize for what matters. ![Device Statistics](https://samuelreichor.at/images/bitmap/craft-insights-devices.png) --- ## Campaign Tracking :badge{label="Pro"} Measure your marketing efforts with full UTM support. ![Campaign Tracking](https://samuelreichor.at/images/bitmap/craft-insights-campaigns.png) [How campaigns are tracked →](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#campaign-tracking) --- ## Country Analytics :badge{label="Pro"} See where your visitors are located - privacy-friendly, no IP storage. ![Country Statistics](https://samuelreichor.at/images/bitmap/craft-insights-countries.png) --- ## User Events :badge{label="Pro"} Track button clicks, form submissions, downloads - anything that matters to your business. ![User Events](https://samuelreichor.at/images/bitmap/craft-insights-events.png) [Implementation guide →](https://samuelreichor.at/libraries/craft-insights/usage/user-events) --- ## Outbound Links :badge{label="Pro"} Know where users go when they leave. Track affiliate links and external resources. ![Outbound Links](https://samuelreichor.at/images/bitmap/craft-insights-outbound-links.png) [Implementation guide →](https://samuelreichor.at/libraries/craft-insights/usage/outbound-tracking) --- ## Site Searches :badge{label="Pro"} Discover what your visitors are looking for. Find content gaps. ![Site Searches](https://samuelreichor.at/images/bitmap/craft-insights-site-searches.png) [Implementation guide →](https://samuelreichor.at/libraries/craft-insights/usage/search-tracking) --- ## Scroll Depth :badge{label="Pro"} See how far visitors actually read. Optimize content placement. ![Scroll Depth](https://samuelreichor.at/images/bitmap/craft-insights-scroll-depth.png) [How scroll depth works →](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#scroll-depth) --- ## Entry & Exit Pages :badge{label="Pro"} Find your best landing pages. Identify where users drop off. ![Entry and Exit Pages](https://samuelreichor.at/images/bitmap/craft-insights-session-insights.png) [How sessions work →](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#session-insights) --- ## AI Bot Analytics When [LLMify](https://samuelreichor.at/libraries/craft-llmify) is installed, see which AI crawlers hit your site, how they get the Markdown, and which pages they read most. ![AI Bot Analytics dashboard](https://samuelreichor.at/images/bitmap/craft-insights-llmify-integration.png) [How AI bot metrics work →](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#ai-bot-analytics) --- ## Craft CMS Integrations ### Entry Sidebar Stats right where you edit. No context switching. ![Entry Sidebar Stats](https://samuelreichor.at/images/bitmap/craft-insights-entry-stats.png) ### Dashboard Widgets Key metrics on your Craft dashboard. ![Insight Widgets](https://samuelreichor.at/images/bitmap/craft-insights-widgets.png) --- ## Export Your Data Every detail page ships with an **Export** dropdown that delivers the current view as **CSV** for spreadsheets and pipelines, or as a **PDF report** for sharing with stakeholders. The dashboard itself can be exported as a multi-section PDF — summary KPIs, a traffic chart, and every top-list — perfect for monthly reviews or client hand-offs. Both formats respect the currently selected site and date range and require the *Export data* user permission. --- ## Scheduled Email Reports Craft Insights can automatically send recurring analytics summaries to any inbox, on a daily, weekly, or monthly schedule. Each report covers your headline KPIs, top pages, and top referrers, and you can optionally attach the full **dashboard PDF** so non-technical stakeholders get a presentation-ready overview straight in their inbox. [Email reports guide →](https://samuelreichor.at/libraries/craft-insights/usage/email-reports) # Privacy & GDPR Insights was built from the ground up with privacy as a core principle. It provides meaningful analytics without compromising user privacy or requiring consent banners. ## No Cookies Insights does not use cookies, localStorage, or any other client-side storage mechanism. This means: - No cookie consent banner required - No "accept cookies" popups - Compliant with strict cookie laws (GDPR, ePrivacy, CCPA) ## No Fingerprinting Unlike many analytics tools, Insights does not fingerprint users. We don't collect or combine: - Canvas fingerprints - WebGL fingerprints - Audio fingerprints - Installed fonts or plugins - Screen resolution (only category: small/medium/large) - Timezone - Hardware characteristics ## Visitor Identification Instead of tracking individuals, Insights uses a daily-rotating hash based on the same approach as [Plausible](https://plausible.io/data-policy){rel=""nofollow""} and [Fathom](https://usefathom.com/blog/anonymization){rel=""nofollow""}: ```text hash = SHA256(salt | date | ip | browser | language | screen) ``` ### Hash Attributes | Attribute | Description | | ------------ | --------------------------------------------------- | | **salt** | Daily random value (prevents rainbow table attacks) | | **date** | Current date - hash is only valid for today | | **ip** | IP address for visitor uniqueness | | **browser** | Browser family only (e.g., "Chrome"), no version | | **language** | Primary browser language (e.g., "de") | | **screen** | Screen category (s/m/l) | **Important:** The IP address is used **only** for generating this hash and is **immediately discarded**. It is never written to any database, log file, or storage. ### Why This Is GDPR Compliant 1. **IP is never stored** - Only used for hash calculation, then discarded 2. **Hash is irreversible** - SHA256 is a one-way function 3. **Daily rotation** - Salt and date change daily, no long-term tracking possible 4. **No cookies/storage** - Nothing is stored on the visitor's device 5. **Legal basis** - Legitimate interest (Art. 6(1)(f) GDPR) ### Properties | Property | Benefit | | --------------------- | -------------------------------------------- | | **Daily rotation** | Same user = new hash tomorrow | | **No persistence** | Nothing stored on user's device | | **Non-reversible** | SHA256 is a one-way hash - cannot recover IP | | **Industry standard** | Same approach as Plausible & Fathom | ## IP Address Handling IP addresses are **never stored** - they are only used transiently for: 1. **Visitor hash generation** - Combined with salt, date, browser, language, and screen to create an anonymous daily identifier 2. **Excluded IP filtering (Pro)** - Your configured IP/CIDR exclusions 3. **GeoIP lookup (Pro)** - Extracting country code only ::alert{variant="note"} Bot detection uses **User-Agent analysis**, not IP addresses. Insights uses [`jaybizzle/crawler-detect`](https://github.com/JayBizzle/Crawler-Detect){rel=""nofollow""} — a regularly updated database of \~1000 crawler signatures including search engines, monitoring tools (UptimeRobot, Pingdom, GTmetrix), headless browsers, and AI scrapers like GPTBot, ClaudeBot and PerplexityBot. The list is maintained upstream so new bots are picked up via Composer updates without code changes. :: ### Data Flow 1. Browser loads `insights.js` 2. JS sends POST to `/actions/insights/track` (User-Agent, Accept-Language, IP sent automatically) 3. Server generates hash via `VisitorService.generateHash()` 4. Database is updated (`uniqueVisitors += 1`) 5. IP is **NOT** stored ```text Request arrives with IP + User-Agent + Accept-Language ↓ Bot check (User-Agent only): "googlebot" → reject ↓ IP exclusion check (Pro): 192.168.1.0/24 → reject ↓ Generate visitor hash: SHA256(salt | date | ip | browser | language | screen) ↓ GeoIP lookup (Pro): IP → "DE" (country code only) ↓ IP immediately discarded (never written to disk) ↓ Only hash and country code stored ``` ### Why This Is Safe | Concern | Protection | | ---------------------- | --------------------------------------------------------------------- | | **Brute-force attack** | Daily salt rotation makes it computationally infeasible | | **Rainbow tables** | Salt + date + browser + language + screen creates unique combinations | | **Cross-day tracking** | New salt daily = new hash = no correlation | ### Limitations | Scenario | Result | | ---------------------- | --------------------------------------- | | Same visitor, same day | Same hash → counted as 1 unique visitor | | Same visitor, next day | New hash → counted as new visitor | | VPN/proxy changes | New IP → counted as new visitor | | Browser changes | New hash → counted as new visitor | The daily salt rotation means Insights cannot distinguish between new and returning visitors across days. The same person visiting today and tomorrow generates two unrelated hashes. This is an intentional privacy trade-off. ### Static Caching Static caching is not a problem since POST requests are not cached. When using a CDN or reverse proxy, ensure `trustedProxies` is configured in Craft to get the correct visitor IP. ## Data Minimization Insights practices strict data minimization: | Collected | Not Collected | | ---------------- | ------------------------- | | Page URL path | Query parameters | | Referrer domain | Full referrer URL | | Browser family | Browser version | | OS family | OS version | | Device type | Device model | | Screen category | Exact resolution | | Primary language | Full Accept-Language | | Country code | City, region, postal code | ## Aggregated Storage Raw events are never stored. All data is immediately aggregated: ```text Pageview → UPDATE stats SET views = views + 1 WHERE url = '/page' ``` This means: - No individual user sessions - No event logs - No user timelines - No way to reconstruct individual behavior ## Data Retention Configure automatic data cleanup: ```php // config/insights.php return [ 'dataRetentionDays' => 365, // Delete data older than 1 year 'autoCleanup' => true, // Run daily cleanup ]; ``` Cleanup is automatic and irreversible. Old data is permanently deleted. ## Do Not Track Insights respects the browser's DNT (Do Not Track) header by default: ```php 'respectDoNotTrack' => true, // Default ``` When enabled: - Requests with `DNT: 1` header are not tracked - No data is collected for these visitors ## User Rights (GDPR) Because Insights doesn't collect personal data: | Right | Applicability | | ------------------------ | -------------------------- | | **Right to access** | No personal data to access | | **Right to erasure** | No personal data to erase | | **Right to portability** | No personal data to export | | **Right to object** | Supported via DNT header | ## Legal Basis Since Insights: - Collects no personal data - Uses no cookies - Performs no fingerprinting - Stores only aggregated statistics It typically falls outside GDPR scope for personal data processing. However, always consult with legal counsel for your specific situation. ## Comparison with Other Tools | Feature | Insights | Plausible | Fathom | Google Analytics | | ---------------- | ----------- | --------- | ------ | ---------------- | | Cookies | None | None | None | Multiple | | IP in hash | Yes | Yes | Yes | N/A | | IP stored | Never | Never | Never | Anonymized | | Daily salt | Yes | Yes | Yes | No | | Data location | Your server | EU/US | US | Google | | Consent required | No | No | No | Yes | | Open source | Yes | Yes | No | No | ## Configuration Checklist For maximum privacy: ```php return [ // Respect browser privacy preferences 'respectDoNotTrack' => true, // Reasonable data retention 'dataRetentionDays' => 365, 'autoCleanup' => true, ]; ``` # Metrics Reference This guide explains every metric you see in the Insights dashboard and how it's calculated. ## Key Metrics ### Pageviews Total number of page loads across your site. ```text Pageviews = SUM(views) from all pages ``` Every time a page loads and the tracking script fires, this counter increments by 1. ### Unique Visitors Number of distinct visitors based on daily visitor hashes. ```text Unique Visitors = COUNT(DISTINCT visitorHash) from sessions ``` A visitor is identified by a daily-rotating hash generated from: ```text hash = SHA256(salt | date | ip | browser | language | screen) ``` | Attribute | Description | | ------------ | ------------------------- | | **salt** | Daily random value | | **date** | Current date | | **ip** | IP address (never stored) | | **browser** | Browser family only | | **language** | Primary browser language | | **screen** | Screen category (s/m/l) | The same person visiting on different days counts as different visitors. See [Privacy](https://samuelreichor.at/libraries/craft-insights/knowledge/privacy) for full details. ### Bounce Rate Percentage of sessions where the visitor viewed only one page. ```text Bounce Rate = (Sessions with 1 pageview / Total sessions) × 100 ``` A "bounce" is when someone visits one page and leaves without viewing another page. Lower is generally better, but depends on content type (blog posts naturally have higher bounce rates). ### Avg. Time on Page Average time visitors spend on pages, in seconds. ```text Avg. Time = Total time on all pages / Total pageviews ``` Time is measured from page load until the user leaves (navigates away, closes tab, or switches tabs). ### Trend Percentages The percentage change compared to the previous period. ```text Trend = ((Current value - Previous value) / Previous value) × 100 ``` For "Last 7 Days", the previous period is the 7 days before that. A positive trend (green) means growth, negative (red) means decline. ::alert{variant="note"} If the previous period has zero data, the trend shows 0% instead of infinity. :: --- ## Referrers ### Visits Number of sessions that came from this referrer source. ### Referrer Types | Type | Definition | | ------------ | ---------------------------------------------------------------------------- | | **Direct** | No referrer - typed URL, bookmark, or app link | | **Search** | Google, Bing, Yahoo, DuckDuckGo, Baidu, Yandex, Ecosia | | **Social** | Facebook, Twitter/X, LinkedIn, Instagram, Pinterest, YouTube, TikTok, Reddit | | **Referral** | Any other external website | --- ## Devices ### Device Type Classified from User-Agent based on device form factor: | Type | Definition | | ----------- | ---------------------------------- | | **Desktop** | Computers and laptops | | **Mobile** | Smartphones | | **Tablet** | Tablets like iPad, Android tablets | ### Browser The browser family (Chrome, Firefox, Safari, Edge, etc.) extracted from User-Agent. --- ## Real-time Visitors Number of visitors currently active on your site. ```text Active Visitors = COUNT(visitors) WHERE lastSeen >= (now - realtimeTtl) ``` Default `realtimeTtl` is 300 seconds (5 minutes). A visitor is considered "active" if they've had any activity within this window. --- ## Campaign Tracking :badge{label="Pro"} ### Visits Number of sessions that arrived with these UTM parameters. ### UTM Parameters | Parameter | Purpose | Example | | -------------- | ---------------- | ------------------------------- | | `utm_source` | Traffic source | `newsletter`, `google` | | `utm_medium` | Marketing medium | `email`, `cpc`, `social` | | `utm_campaign` | Campaign name | `spring_sale`, `product_launch` | | `utm_term` | Paid keywords | `running shoes` | | `utm_content` | Ad variation | `banner_a`, `text_link` | --- ## Countries :badge{label="Pro"} ### Visits Number of sessions from visitors in this country. Country is determined via GeoIP lookup from the visitor's IP address. The IP is immediately discarded after lookup, only the country code is stored. --- ## User Events :badge{label="Pro"} ### Count Total number of times this event was triggered. ### Unique Visitors Number of distinct visitors who triggered this event. --- ## Outbound Links :badge{label="Pro"} ### Clicks Total number of clicks on external links to this domain. ### Unique Visitors Number of distinct visitors who clicked links to this domain. --- ## Site Searches :badge{label="Pro"} ### Searches Number of times this search term was used. ### Unique Visitors Number of distinct visitors who searched for this term. --- ## Scroll Depth :badge{label="Pro"} ### Milestones | Milestone | Meaning | | --------- | --------------------------------------- | | **25%** | Visitor scrolled past the first quarter | | **50%** | Visitor reached mid-page | | **75%** | Visitor showed deep engagement | | **100%** | Visitor scrolled to the bottom | Each milestone is counted once per pageview. If a user scrolls down to 75%, then back up, then down to 100%, both 75% and 100% are counted (but 75% only once). ### Average Scroll Depth ```text Avg. Scroll Depth = (25% × count_25 + 50% × count_50 + 75% × count_75 + 100% × count_100) / total_milestone_events ``` This weighted average shows how far visitors typically scroll across your site. --- ## Session Insights :badge{label="Pro"} ### Pages per Session ```text Pages per Session = AVG(pageCount) from all sessions ``` Average number of pages viewed in a single session. Higher values indicate visitors are exploring more content. ### Entry Pages The first page a visitor sees when starting a session. High-performing entry pages are good landing pages. ### Exit Pages The last page a visitor sees before leaving. Pages with high exit rates might need improvement (or are natural endpoints like "Thank You" pages). ### Session Definition A session groups pageviews from the same visitor. A new session starts when: 1. **First visit** - Visitor has no previous activity 2. **Timeout** - More than 30 minutes since last pageview ```text Session timeout = 30 minutes of inactivity ``` --- ## AI Bot Analytics Available when [LLMify](https://samuelreichor.at/libraries/craft-llmify) is installed. Every Markdown response LLMify serves is counted: requests to `.md` pages, `llms.txt`, `llms-full.txt`, content-negotiated swaps, and bot-detected responses. ### Total Visits Total Markdown deliveries in the selected range, split between **Bots** (requests with a recognized AI crawler User-Agent) and **Humans** (everyone else, e.g. someone manually opening a `.md` URL). ```text Total Visits = Bots + Humans ``` ### Unique Bots Number of distinct AI crawlers that requested at least one Markdown response in the selected range. ```text Unique Bots = COUNT(DISTINCT botName) WHERE botName != '' ``` ### Top Crawler The bot with the highest request count in the selected range, shown with its share of total bot traffic. ```text Top Bot Share = (Top Bot Requests / Total Bot Requests) × 100 ``` ### Delivery Split How AI agents got the Markdown. Every request is classified as one of two types: | Type | Definition | | -------------- | ------------------------------------------------------------------------------------------------------------------------- | | **Direct** | Agent requested a Markdown URL explicitly (`/raw/.md`, `/llms.txt`, `/llms-full.txt`, `/.well-known/llms.txt`) | | **Negotiated** | Agent requested the regular page URL and LLMify swapped the response to Markdown via content negotiation or bot detection | ```text Direct Share = (Direct Requests / Total Requests) × 100 Negotiated Share = (Negotiated Requests / Total Requests) × 100 ``` ### Crawl Activity Daily request volume across the selected range. The chart can be grouped by: - **Total**: One bar per day with the combined request count - **By Bot**: Stacked per crawler so you can spot which bot is most active on a given day - **By Delivery**: Stacked by Direct vs Negotiated to see how agents access your content ### Crawlers Table Per-bot leaderboard with total request counts in the selected range. Identified via User-Agent string against LLMify's bundled AI crawler list. ### Top Visited Markdowns The Markdown URLs (`.md` pages, `llms.txt`, `llms-full.txt`) ranked by total visits. URLs are normalised to their path so the same page lands on the same row regardless of direct or content negotiation. --- ## Data Ranges Available date ranges and their definitions: | Range | Period | | ------------------ | -------------------------------------- | | **Today** | Current calendar day (midnight to now) | | **Last 7 Days** | Today + previous 6 days | | **Last 30 Days** | Today + previous 29 days | | **Last 90 Days** | Today + previous 89 days | | **Last 12 Months** | Today + previous 365 days | Trend comparisons use the equivalent previous period (e.g., "Last 7 Days" compares to the 7 days before that). # Twig Functions ## `trackingScript()` Registers the tracking script via Craft's asset bundle system. ```twig {{ craft.insights.trackingScript() }} ``` ::alert{variant="note"} Technically it's not important where you add the script on your page as it always injects the script at the end of the body. :: ## `trackingScriptInline()` Outputs the tracking script as inline JavaScript. ```twig {{ craft.insights.trackingScriptInline()|raw }} ``` Use this when asset bundles aren't available (e.g., in certain caching scenarios). # User Events User events let you track specific user interactions beyond pageviews. Use them to measure conversions, engagement, and feature usage. ## Data Attributes Track events declaratively using HTML data attributes: ```html ``` When clicked, these elements automatically trigger the corresponding event. No JavaScript required. ## `trackEvent()` Track a custom event programmatically: ```javascript window.insights.trackEvent(name, options) ``` **Parameters:** | Parameter | Type | Required | Description | | ------------------ | -------- | -------- | ----------------------------- | | `name` | `string` | Yes | Event name (max 100 chars) | | `options.category` | `string` | No | Event category (max 50 chars) | **Example:** ```javascript // Track a newsletter signup window.insights.trackEvent('newsletter_signup', { category: 'conversion' }); // Track a file download window.insights.trackEvent('download_pdf', { category: 'engagement' }); // Track a button click document.querySelector('#cta-button').addEventListener('click', function() { window.insights.trackEvent('cta_clicked'); }); ``` ## Best Practices ### Naming Conventions Use consistent, descriptive event names: ```javascript // Good - clear and consistent window.insights.trackEvent('form_submitted', { category: 'contact' }); window.insights.trackEvent('video_played', { category: 'engagement' }); window.insights.trackEvent('pricing_viewed', { category: 'conversion' }); // Avoid - vague or inconsistent window.insights.trackEvent('click'); window.insights.trackEvent('btn1'); ``` ### Categories Group related events with categories: | Category | Use For | | ------------ | --------------------------------------- | | `conversion` | Signups, purchases, form submissions | | `engagement` | Video plays, scroll depth, time on page | | `navigation` | Menu clicks, search usage | | `error` | Form validation errors, 404 pages | ### Form Tracking Track form submissions: ```javascript document.querySelector('form').addEventListener('submit', function(e) { window.insights.trackEvent('form_submitted', { category: 'conversion' }); }); ``` ### Video Tracking Track video engagement: ```javascript const video = document.querySelector('video'); video.addEventListener('play', function() { window.insights.trackEvent('video_started', { category: 'engagement' }); }); video.addEventListener('ended', function() { window.insights.trackEvent('video_completed', { category: 'engagement' }); }); ``` ## Viewing Events User events appear in: 1. **Dashboard** - [User Events](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#user-events) card shows top events 2. **Events Page** - Full event list with filters # Outbound Link Tracking Outbound link tracking helps you understand which external resources your visitors find valuable and where they go when leaving your site. ## Automatic Tracking External links are tracked automatically. When a user clicks a link to a different domain, Insights captures: - Target URL - Target domain - Link text - Source page URL **Example:** ```html View on GitHub ``` ## Excluding Links Prevent tracking on specific links with the `data-insights-no-track` attribute: ```html External Link ``` Use this for: - Advertising links (to avoid inflating metrics) - Partner links you don't want to track - Navigation to your own external properties ## Manual Tracking Track outbound clicks programmatically: ```javascript window.insights.trackOutbound(url, text) ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------- | | `url` | `string` | Yes | Target URL | | `text` | `string` | No | Link text (max 255 chars) | **Example:** ```javascript // Track a programmatic redirect window.insights.trackOutbound('https://partner.com/offer', 'Partner Offer'); window.location.href = 'https://partner.com/offer'; ``` ## What Gets Tracked The automatic tracker captures clicks on `` elements where: 1. The `href` points to a different hostname 2. The protocol is `http:` or `https:` 3. The link doesn't have `data-insights-no-track` **Tracked:** ```html GitHub Example ``` **Not tracked:** ```html Internal Link Email Phone Excluded ``` ## Viewing Outbound Data Outbound link data appears in: 1. **Dashboard** - [Outbound Links](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#outbound-links) card shows top clicked domains 2. **Outbound Page** - Full list with URLs, domains, and click counts ## Use Cases ### Identify Valuable Resources See which external resources your visitors find most useful: - Documentation links - Partner websites - Social media profiles - Download sources ### Optimize Affiliate Links Track affiliate link performance: ```html Buy Now ``` Combine with user events for detailed conversion tracking. ### Monitor Exit Points Identify pages where users commonly leave your site to external destinations. # Search Tracking Site search tracking reveals what your visitors are looking for, helping you improve content and navigation. ## `trackSearch()` Track search queries programmatically: ```javascript window.insights.trackSearch(query, resultsCount) ``` **Parameters:** | Parameter | Type | Required | Description | | -------------- | -------- | -------- | ---------------------------- | | `query` | `string` | Yes | Search query (max 255 chars) | | `resultsCount` | `number` | No | Number of results found | **Example:** ```javascript // After performing a search const query = searchInput.value; const results = performSearch(query); window.insights.trackSearch(query, results.length); ``` ## Integration Examples ### Basic Search Form ```javascript document.querySelector('#search-form').addEventListener('submit', function(e) { const query = document.querySelector('#search-input').value; // Track the search window.insights.trackSearch(query); }); ``` ### Craft Search Integration Track Craft's native search: ```twig {# In your search results template #} {% set query = craft.app.request.getParam('q') %} {% set results = craft.entries.search(query).all() %} ``` ### Algolia Integration ```javascript search.on('render', function() { const query = search.helper.state.query; const hits = search.helper.lastResults.nbHits; if (query) { window.insights.trackSearch(query, hits); } }); ``` ## Viewing Search Data Search analytics appear in: 1. **Dashboard** - [Site Searches](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#site-searches) card shows popular queries 2. **Searches Page** - Full query list with search counts ## Insights from Search Data ### Popular Searches Identify what visitors search for most frequently: | Query | Searches | Results | | ------------- | -------- | ------- | | pricing | 245 | 3 | | documentation | 189 | 12 | | api | 156 | 8 | ### Zero-Result Searches Queries with zero results indicate content gaps: | Query | Searches | Results | | ------------- | -------- | ------- | | refund policy | 45 | 0 | | enterprise | 32 | 0 | | integrations | 28 | 0 | Consider creating content for these topics. ## Best Practices ### Normalize Queries Searches should automatically be trimmed. For additional normalization: ```javascript function trackNormalizedSearch(query, results) { // Remove extra whitespace const normalized = query.trim().replace(/\s+/g, ' '); if (normalized) { window.insights.trackSearch(normalized, results); } } ``` ### Debounce Instant Search For search-as-you-type interfaces, debounce tracking: ```javascript let searchTimeout; searchInput.addEventListener('input', function() { clearTimeout(searchTimeout); searchTimeout = setTimeout(function() { const query = searchInput.value; if (query.length >= 3) { performSearch(query); window.insights.trackSearch(query); } }, 500); }); ``` ### Track Result Clicks Combine search tracking with event tracking: ```javascript function trackResultClick(query, resultUrl) { window.insights.trackEvent('search_result_click', { category: 'search' }); } ``` # Email Reports Insights can send a recurring analytics report to a list of email recipients. This is a simple way to keep clients, marketing teams, or other stakeholders up to date. The email contains an HTML summary with the most important KPIs, top pages, and top referrers for the period. You can also attach the **full dashboard PDF**, so recipients get a complete report directly in their inbox. ::alert{variant="note"} Email delivery uses Craft's configured mailer (the same one used for password resets and other system mails). :: ## Configuration Open *Settings → Insights → Notifications* in the control panel: | Setting | Description | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | | **Email Frequency** | How often the report is sent. `Never` (default), `Weekly`, `Every two weeks`, or `Monthly`. | | **Recipients** | One row per email address. Each recipient gets the same report. | | **Attach Report PDF** | When on, attaches the full dashboard PDF to every send. Default: on. | | **Send Reports via Cron Only** | Turns off the automatic check. Reports are only sent through the [console command](https://samuelreichor.at/#console-command). | You can also set all of these in `config/insights.php` — see [Configuration → emailFrequency / emailRecipients / attachPdfReport / useCronForEmails](https://samuelreichor.at/libraries/craft-insights/get-started/configuration#emailfrequency). ## How Scheduling Works Insights does not need a cron job. On normal site requests, the plugin checks if a report is due. This check is cached, so it runs at most **once per hour**, no matter how much traffic you get. When enough time has passed since the last sent report, a `SendNotificationReport` job is pushed to the queue. ### Duplicate Protection Two safeguards prevent duplicate reports: - Only **one report job** can be in the queue at a time. While a job is waiting, the hourly check does not add another one. - Before sending, the job **checks again** that the report is still due and that the frequency setting has not changed. Old or duplicate jobs — for example after the queue was stopped for a while — skip themselves and send nothing. Setting the frequency to `Never` also cancels jobs that are already in the queue. | Frequency | Interval | Stats range used | | --------------- | -------- | ---------------- | | Weekly | 7 days | Last 7 days | | Every two weeks | 14 days | Last 14 days | | Monthly | 28 days | Last 30 days | The stats range always covers the time since the previous send. Each report describes the period between two emails, with no gaps and no overlaps. ## Audit Log Every send (and every failure) is stored in the `insights_notification_log` database table, together with the frequency, recipient count, status, and any error message. The status shows the job lifecycle: `queued` (job is waiting), `sent`, `failed`, or `skipped` (a duplicate or outdated job that did not send). The timestamp of the last successful send decides when the next report is due. So even if your site gets no traffic for a while, no duplicate emails go out when it comes back. The regular data cleanup removes log entries older than [`dataRetentionDays`](https://samuelreichor.at/libraries/craft-insights/get-started/configuration#dataretentiondays). ## Send Test Mail Below the recipients table there is a **Send Test Mail** button. It sends the report to your own address. Save your settings first, then send the test mail. ## Console Command You can also send a report from the command line: ```bash # Send the report for the configured frequency, but only if it is due php craft insights/notifications/send # Send now, even if the report is not due yet php craft insights/notifications/send --force # Send a specific frequency variant on demand php craft insights/notifications/send --frequency=monthly --force ``` Available `--frequency` values: `weekly`, `biweekly`, `monthly`. If you want reports to always go out at a fixed time, enable **Send Reports via Cron Only** ([`useCronForEmails`](https://samuelreichor.at/libraries/craft-insights/get-started/configuration#usecronforemails)). This turns off the automatic check. Reports are then only sent by a scheduled command: ```bash # Runs daily at 8:00. The due-check makes sure the report # is still only sent once per configured interval. 0 8 * * * php /path/to/craft insights/notifications/send ``` ## PDF Attachment When **Attach Report PDF** is enabled, every email (including test mails) includes a PDF that matches the manual dashboard PDF export: - Period header with the exact date range - Summary KPIs (Pageviews, Unique Visitors, Avg. Time / Page, Bounce Rate) - Traffic chart (pageviews + unique visitors per day) - Top Pages, Traffic Sources, Devices, Browsers - Pro tables when available (Top Countries, Campaigns, Events, Outbound Links, Searches, Entry/Exit Pages, Scroll Depth) The PDF is generated with [dompdf](https://github.com/dompdf/dompdf){rel=""nofollow""} at send time. If the PDF rendering fails, a warning is logged and the HTML email is still sent without the attachment. # Insights ![Insights Dashboard Overview](https://samuelreichor.at/images/bitmap/craft-insights-dashboard.png) Insights is a analytics plugin for Craft CMS. All data stays on your server, no third-party services, no cookie banners, fully GDPR-compliant out of the box. ## Why Insights? | | Insights | Google Analytics | | --------------------------------- | :------: | :--------------: | | GDPR-compliant without consent | ✅ | ❌ | | No cookie banners needed | ✅ | ❌ | | Data stays on your server | ✅ | ❌ | | No data shared with third parties | ✅ | ❌ | | Native Craft CMS integration | ✅ | ❌ | | Lightweight (\~3KB, async) | ✅ | ❌ | ## How It Works 1. Add the tracking script to your templates 2. View your dashboard in the Craft control panel ```twig {{ craft.insights.trackingScript() }} ``` That's it. Insights automatically tracks pageviews, referrers, and devices. No configuration required. ## Privacy by Design Insights doesn't collect personal data: - **No cookies** - Nothing stored on the user's device - **No fingerprinting** - Uses daily-rotating visitor hashes - **No IP storage** - IPs are used for GeoIP lookup, then immediately discarded - **Aggregated data only** - Raw events are never stored This means no consent banners, no privacy policy updates, and no GDPR headaches. ## Lite vs Pro **Lite** covers the essentials-pageviews, referrers, devices, and real-time visitors. Perfect for blogs and smaller sites. **Pro** adds marketing features: campaign tracking (UTM), country stats, user events, scroll depth, session insights, and more. Built for teams that need deeper insights. | Feature | Lite | Pro | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--: | :-: | | [Key Metrics](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#key-metrics) | ✅ | ✅ | | [Referrer Analysis](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#referrers) | ✅ | ✅ | | [Real-time Visitors](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#real-time-visitors) | ✅ | ✅ | | [Device Breakdown](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#devices) | ✅ | ✅ | | Entry Sidebar | ✅ | ✅ | | Data Export (CSV & PDF) | ✅ | ✅ | | [Scheduled Email Reports](https://samuelreichor.at/libraries/craft-insights/usage/email-reports) | ✅ | ✅ | | Widgets | ✅ | ✅ | | [Custom Queue](https://samuelreichor.at/libraries/craft-insights/get-started/configuration#custom-queue) | ✅ | ✅ | | [AI Bot Analytics](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#ai-bot-analytics) (requires [LLMify](https://samuelreichor.at/libraries/craft-llmify)) | ✅ | ✅ | | [Country Tracking](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#countries) | ❌ | ✅ | | [User Events](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#user-events) | ❌ | ✅ | | [Scroll Depth Tracking](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#scroll-depth) | ❌ | ✅ | | [Session Insights](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#session-insights) | ❌ | ✅ | | [Entry & Exit Pages](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#entry-pages) | ❌ | ✅ | | [Outbound Link Tracking](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#outbound-links) | ❌ | ✅ | | [Campaign Tracking (UTM)](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#campaign-tracking) | ❌ | ✅ | | [Site Search Analytics](https://samuelreichor.at/libraries/craft-insights/knowledge/how-tracking-works#site-searches) | ❌ | ✅ | | [Custom Database](https://samuelreichor.at/libraries/craft-insights/get-started/configuration#external-database) | ❌ | ✅ | ## Getting Started ::card-group :::card --- title: Installation to: https://samuelreichor.at/libraries/craft-insights/get-started/installation --- Install via Composer or the Craft Plugin Store. ::: :::card --- title: Configuration to: https://samuelreichor.at/libraries/craft-insights/get-started/configuration --- Customize tracking behavior and privacy settings. ::: :: # Installation ## Requirements - Craft CMS 5.0.0 or later - PHP 8.2 or later ## Craft Plugin Store To install Custom Queue Manager, navigate to the Plugin Store in your Craft control panel, search for "Custom Queue Manager," and click **Install**. ## Composer ::code-group ```bash [ddev] ddev composer require samuelreichor/craft-custom-queue-manager && ddev craft plugin/install custom-queue-manager ``` ```bash [php] composer require samuelreichor/craft-custom-queue-manager && php craft plugin/install custom-queue-manager ``` :: ## Register Custom Queues Custom Queue Manager only monitors custom queues. You need to register at least one custom queue in your `config/app.php`: ```php ['emailQueue'], 'components' => [ 'emailQueue' => [ 'class' => \craft\queue\Queue::class, ], ], ]; ``` After registering a custom queue, the "Custom Queues" utility will appear under Utilities in the control panel. ## Running Custom Queues Custom queues need to be run separately from Craft's default queue. Each registered queue component gets its own console command using the kebab-case version of the component ID (e.g., `emailQueue` becomes `email-queue`): ```bash # Run once php craft email-queue/run --verbose # Run as daemon php craft email-queue/listen --verbose ``` ### DDEV Queue Runner To run custom queues automatically in DDEV, add `web_extra_daemons` to your `.ddev/config.yaml`. Each daemon runs as a separate supervisor process that automatically restarts on failure: ```yaml [.ddev/config.yaml] web_extra_daemons: - name: "queue-default" command: "php /var/www/html/craft queue/listen --verbose" directory: /var/www/html - name: "queue-email" command: "php /var/www/html/craft email-queue/listen --verbose" directory: /var/www/html ``` After updating the config, restart DDEV: ```bash ddev restart ``` # Configuration ## Control Panel You can manage configuration settings through the Control Panel by visiting Settings → Custom Queue Manager. ## Settings You can define a multi environment aware config in `/config/custom-queue-manager.php`. Settings defined in the config file override control panel settings. ### `refreshInterval` Auto-refresh interval for the queue monitor dashboard in milliseconds. ```php return [ '*' => [ 'refreshInterval' => 2000, // default ], ] ``` ### `jobsPerPage` Maximum number of jobs to display per queue. ```php return [ '*' => [ 'jobsPerPage' => 50, // default ], ] ``` ### `enableEmailNotifications` When enabled, Custom Queue Manager sends an email on the first failure attempt of any queue job. The email includes the job description, queue name, and error message with a link to the control panel. ```php return [ '*' => [ 'enableEmailNotifications' => false, // default ], ] ``` ::alert{variant="note"} You can customize the email template by going to Settings → Email → System Messages and editing the "When a queue job fails" message. :: ### `notificationEmail` The email address to send failure notifications to. Required when `enableEmailNotifications` is enabled. ```php return [ '*' => [ 'notificationEmail' => 'admin@example.com', ], ] ``` # Custom Queue Manager Custom Queue Manager adds a utility to the Craft CMS control panel for monitoring and managing custom queue jobs. It auto-discovers all custom queues registered in your app config and provides a real-time dashboard with job management capabilities. ![Custom Queue Manager Overview](https://samuelreichor.at/images/bitmap/craft-custom-queue-manager-overview.png) ## Features - **Custom Queue Discovery**: Automatically finds all custom queues registered in your Craft app config - **Real-time Monitoring**: Auto-refreshing dashboard shows job status, progress, and statistics - **Job Management**: Retry failed jobs or release jobs directly from the control panel - **Bulk Actions**: Retry all failed jobs or release all jobs across one or all queues - **Job Details**: Inspect individual jobs including class, status, progress, and error messages - **Email Notifications**: Get notified via email when a queue job fails - **Customizable System Messages**: Edit the failure notification email template via Craft's System Messages ## How It Works 1. Register custom queues in your `config/app.php` 2. The plugin auto-discovers them and adds a utility to the control panel 3. Monitor and manage jobs through the dashboard ::alert{variant="note"} The utility only appears when at least one custom queue is configured. The plugin does not manage Craft's default queue. :: ## Getting Started ::card-group :::card --- title: Installation to: https://samuelreichor.at/libraries/craft-custom-queue-manager/get-started/installation --- Install via Composer or the Craft Plugin Store. ::: :::card --- title: Configuration to: https://samuelreichor.at/libraries/craft-custom-queue-manager/get-started/configuration --- Set up custom queues and configure plugin settings. ::: :: # Installation & Setup ## Requirements - Supports Craft CMS > 5 - PHP 8.2 or later ## Craft Plugin Store To install CoPilot, go to the Plugin Store in your Craft control panel, search for "CoPilot," and click the Try button. ## Composer ::code-group ```bash [ddev] ddev composer require samuelreichor/craft-co-pilot && ddev craft plugin/install co-pilot ``` ```bash [php] composer require samuelreichor/craft-co-pilot && php craft plugin/install co-pilot ``` :: ## Setup ### Add Providers To get started you need to pick one or more AI providers and generate an API key. If you choose more than one you can switch the provider in the chat. ::alert{variant="note"} OpenAI offers the best cost-performance balance, Anthropic has the highest quality but is slower and pricier and Gemini is the cheapest but least reliable for complex tasks. :br Head over to the [provider overview](https://samuelreichor.at/libraries/craft-co-pilot/get-started/ai-provider), if you are unsure what provider you should pick. :: To set the api key, first create new environment variables in `.env`: ```sh [.env] OPENAI_API_KEY=XXXXXXXX ANTHROPIC_API_KEY=XXXXXXXX GEMINI_API_KEY=XXXXXXXX ``` After that, you can set them in the control panel settings. For that you can go to **Settings** -> **CoPilot** -> **Providers**: :video-player{alt="Craft CoPilot setup guide" src="https://samuelreichor.at/videos/craft-co-pilot-setup.mp4"} :br ::alert{variant="warning"} Only use set the API key as enviroment variable. Settings get saved in the `project.yaml` and that file will be commited to git. Therefore you would leak your API Key if you save them directly in the settings. :: ### Set Permissions It's recommended that you check the current craft site and adjust permissions for the CoPilot if needed. You can give the Agent access to Sections, Volumes and Categories. These permissions have higher priority than user permissions. For example admin users can't edit entries if they are in sections that are blocked or read only. Learn more about how permissions work in the [Permissions](https://samuelreichor.at/libraries/craft-co-pilot/usage/permissions) guide. :video-player{alt="Craft CoPilot permission settings" src="https://samuelreichor.at/videos/craft-co-pilot-permissions.mp4"} ### Done You can now safely use the Copilot 🚀 ## Support If you encounter bugs or have feature requests, [please submit an issue](https://github.com/samuelreichor/craft-co-pilot/issues/new?template=bug-report.yaml){rel=""nofollow""} or use the `/bug-report` command in the chat window. Your feedback helps improve the plugin! ## Licensing You can try CoPilot in a development environment for as long as you like. Once your site goes live, you are required to purchase a license for the plugin. For more information, see Craft's Commercial [Plugin Licensing](https://craftcms.com/docs/4.x/plugins.html#commercial-plugin-licensing){rel=""nofollow""}. # AI Providers CoPilot supports multiple AI providers out of the box. Each provider has different strengths, pricing, and model options. You can switch between providers at any time in the chat. | Provider | Models | Web Search | Best For | | ------------- | ------ | ---------- | ------------------------ | | OpenAI | 6 | Yes | Cost-performance balance | | Anthropic | 2 | Yes | Highest quality output | | Google Gemini | 5 | No | Budget-friendly usage | ## OpenAI OpenAI is the most widely used provider and offers a good balance between quality, speed, and cost. To get started, create an account at [platform.openai.com](https://platform.openai.com){rel=""nofollow""} and generate an API key. ::field-group :::field{name="Models"} `gpt-5.4` · `gpt-5.4-mini` · `gpt-5.4-nano` · `gpt-4o` · `o3` · `o4-mini` ::: :::field{name="Web Search"} Yes ::: :::field{name="Strengths"} Fast responses, reliable tool calling, good all-rounder for content tasks ::: :::field{name="Weaknesses"} Can be less creative than Anthropic for nuanced writing ::: :: ## Anthropic Anthropic builds Claude, known for high-quality, nuanced text generation. Create an account at [console.anthropic.com](https://console.anthropic.com){rel=""nofollow""} and generate an API key. ::field-group :::field{name="Models"} `claude-opus-4-7` · `claude-opus-4-6` · `claude-sonnet-4-6` ::: :::field{name="Web Search"} Yes ::: :::field{name="Strengths"} Best writing quality, strong at following complex instructions, excellent for brand voice tasks ::: :::field{name="Weaknesses"} Slower response times and higher cost per token compared to OpenAI and Gemini ::: :: ## Google Gemini Google Gemini is the most affordable option with generous free tiers. Create an account at [aistudio.google.com](https://aistudio.google.com){rel=""nofollow""} and generate an API key. ::field-group :::field{name="Models"} `gemini-3.1-pro-preview` · `gemini-3-flash-preview` · `gemini-3.1-flash-lite-preview` · `gemini-2.5-pro` · `gemini-2.5-flash` ::: :::field{name="Web Search"} No ::: :::field{name="Strengths"} Lowest cost, fast response times, good for simple content tasks ::: :::field{name="Weaknesses"} Less reliable for complex tool calling, no web search support ::: :: ## Langdock [Langdock](https://langdock.com){rel=""nofollow""} is a DSGVO-compliant AI platform that routes requests to OpenAI, Anthropic, and Google Gemini through a single unified API. By installing the [Langdock Provider Plugin](https://github.com/samuelreichor/craft-co-pilot-langdock){rel=""nofollow""}, all CoPilot AI requests are routed through Langdock instead of directly to the providers. This gives you a single API key, EU or US data residency, and DSGVO-compliant usage of LLMs. ::field-group :::field{name="Installation"} ::::code-group ```bash [ddev] ddev composer require samuelreichor/craft-co-pilot-landock && ddev craft plugin/install co-pilot-landock ``` ```bash [php] composer require samuelreichor/craft-co-pilot-landock && php craft plugin/install co-pilot-landock ``` :::: ::: :::field{name="Models"} All models from OpenAI, Anthropic, and Google Gemini available in your Langdock workspace ::: :::field{name="Data Residency"} EU or US ::: :::field{name="Strengths"} Single API key for all providers, DSGVO-compliant, centralized model management ::: :: ## Benchmark We ran 9 real-world content scenarios (field editing, nested matrix, translations, entry creation, batch operations, multi-site, propagation, and search) against each provider. Here are the results: ### Premium Models | Provider / Model | Avg. Score | Avg. Duration | | --------------------------------- | ---------- | ------------- | | OpenAI (`gpt-5.4`) | 100% | 27.4s | | Anthropic (`claude-opus-4-6`) | 100% | 52.5s | | Gemini (`gemini-3.1-pro-preview`) | 96.3% | 58.0s | ### Budget Models | Provider / Model | Avg. Score | Avg. Duration | | ------------------------------- | ---------- | ------------- | | OpenAI (`o4-mini`) | 87.8% | 149.8s | | Anthropic (`claude-sonnet-4-6`) | 95.6% | 40.8s | | Gemini (`gemini-2.5-flash`) | 91.9% | 23.3s | :br ::alert{variant="note"} Anthropic uses significantly fewer tokens per request due to prompt caching, which makes it very cost-efficient despite higher per-token pricing. Gemini's budget models offer the fastest response times. Scores reflect correctness of the final result across all scenarios. :: ## Custom Providers You can add your own AI provider by implementing the `ProviderInterface`. Head over to the [Custom Providers](https://samuelreichor.at/libraries/craft-co-pilot/developers/custom-providers) guide for a full walkthrough. # Configuration ## Control Panel You can manage configuration settings through the Control Panel by visiting Settings → CoPilot. ## Settings You can define a multi environment aware config in `/config/co-pilot.php`. Settings defined in the config file override control panel settings. ### `defaultProvider` The default AI provider used for new conversations. Available values: `openai`, `anthropic`, `gemini`. ```php return [ '*' => [ 'defaultProvider' => 'openai', // default ], ] ``` ### `providerSettings` Per-provider configuration including API key environment variable and model selection. ```php return [ '*' => [ 'providerSettings' => [ 'openai' => [ 'apiKeyEnvVar' => '$OPENAI_API_KEY', 'model' => 'gpt-5.4', ], 'anthropic' => [ 'apiKeyEnvVar' => '$ANTHROPIC_API_KEY', 'model' => 'claude-sonnet-4-6', ], 'gemini' => [ 'apiKeyEnvVar' => '$GEMINI_API_KEY', 'model' => 'gemini-2.5-flash', ], ], ], ] ``` ::alert{variant="warning"} Only reference API keys as environment variables. Settings are saved in `project.yaml` which is committed to git. Storing keys directly would leak them. :: ### `sectionAccess` Control the agent's access level per section. Maps section UIDs to access levels: `blocked`, `readOnly`, `readWrite`. ```php return [ '*' => [ 'sectionAccess' => [ 'section-uid-here' => 'readOnly', 'another-section-uid' => 'blocked', ], ], ] ``` ::alert{variant="note"} These permissions take priority over user permissions. Even admin users cannot edit entries in sections that are blocked or read-only. See [Permissions](https://samuelreichor.at/libraries/craft-co-pilot/usage/permissions) for details on how both layers interact. :: ### `volumeAccess` Control the agent's access level per asset volume. Same access levels as `sectionAccess`. ```php return [ '*' => [ 'volumeAccess' => [ 'volume-uid-here' => 'readOnly', ], ], ] ``` ### `categoryGroupAccess` Control the agent's access level per category group. Same access levels as `sectionAccess`. ```php return [ '*' => [ 'categoryGroupAccess' => [ 'category-group-uid-here' => 'readWrite', ], ], ] ``` ### `blockedElementTypes` List of element type classes the agent cannot interact with. Commerce Orders are blocked by default. ```php return [ '*' => [ 'blockedElementTypes' => [ 'craft\commerce\elements\Order', // default ], ], ] ``` ### `webSearchEnabled` Allow the agent to search the web for information. Check out the [provider docs](https://samuelreichor.at/libraries/craft-co-pilot/get-started/ai-provider) to see if your configured provider has access to web search tools. ```php return [ '*' => [ 'webSearchEnabled' => false, // default ], ] ``` ### `agentExecutionMode` How the agent executes tool calls. Available values: `supervised` (requires user approval), `autonomous` (auto-execute). ```php return [ '*' => [ 'agentExecutionMode' => 'supervised', // default ], 'dev' => [ 'agentExecutionMode' => 'autonomous', ], ] ``` ### `maxAgentIterations` Maximum number of tool-calling loops per message. Valid range: 1–200. ```php return [ '*' => [ 'maxAgentIterations' => 50, // default ], ] ``` ### `defaultSerializationDepth` Default nesting depth when serializing entries to JSON for the agent. Valid range: 1–10. ```php return [ '*' => [ 'defaultSerializationDepth' => 3, // default ], ] ``` ### `maxSerializationDepth` Maximum allowed nesting depth for entry serialization. Valid range: 1–10. ```php return [ '*' => [ 'maxSerializationDepth' => 4, // default ], ] ``` ### `maxContextTokens` Maximum number of tokens in the agent's context window. Valid range: 1,000–1,000,000. ```php return [ '*' => [ 'maxContextTokens' => 200000, // default ], ] ``` ### `defaultSearchLimit` Default number of results returned by search tools. Valid range: 1–200. ```php return [ '*' => [ 'defaultSearchLimit' => 50, // default ], ] ``` ### `elementUpdateBehavior` How entry updates are persisted. - `provisionalDraft`: Saves changes as a provisional draft. The original entry stays unchanged until an editor reviews and applies the draft. Best for production environments where content changes should be reviewed. - `draft`: reates a new draft for each update. Similar to provisional drafts but creates a separate named draft. - `directSave`: Saves changes directly to the live entry. No draft or review step. Use with caution in production. ```php return [ '*' => [ 'elementUpdateBehavior' => 'provisionalDraft', // default ], ] ``` ### `elementCreationBehavior` How new entries are created. - \`draft: Creates new entries as unpublished drafts. An editor must review and publish them manually. Recommended for most setups. - `directSave`: Creates new entries as published and immediately live. Use with caution. - `disabled`: Creates new entries in a disabled state. Useful when entries need to be reviewed and manually enabled before going live. ```php return [ '*' => [ 'elementCreationBehavior' => 'draft', // default ], ] ``` ### `pluginName` Custom display name for the plugin in the control panel sidebar. Max 50 characters. ```php return [ '*' => [ 'pluginName' => 'CoPilot', // default ], ] ``` ### `auditLogRetentionDays` Number of days to retain audit log entries. Cleanup runs via Craft's garbage collection. Valid range: 1–365. ```php return [ '*' => [ 'auditLogRetentionDays' => 30, // default ], ] ``` ### `debug` Enable debug mode for additional logging and diagnostic information. ```php return [ '*' => [ 'debug' => false, // default ], 'dev' => [ 'debug' => true, ], ] ``` ## Multi-Environment Example A complete example showing environment-specific configuration: ```php [ 'defaultProvider' => 'anthropic', 'providerSettings' => [ 'anthropic' => [ 'apiKeyEnvVar' => '$ANTHROPIC_API_KEY', 'model' => 'claude-sonnet-4-6', ], 'openai' => [ 'apiKeyEnvVar' => '$OPENAI_API_KEY', 'model' => 'gpt-5.4', ], ], 'agentExecutionMode' => 'supervised', 'webSearchEnabled' => true, 'elementCreationBehavior' => 'draft', 'elementUpdateBehavior' => 'provisionalDraft', 'auditLogRetentionDays' => 90, ], 'dev' => [ 'agentExecutionMode' => 'autonomous', 'debug' => true, ], 'production' => [ 'maxAgentIterations' => 30, ], ]; ``` # Chat The chat is the main interface to interact with CoPilot. You can open it from the control panel sidebar or directly from an entry editor. ## Main Chat The main chat is accessible from the control panel sidebar under the CoPilot menu item. Use it for general content tasks like creating entries, searching content, or translating across sites. ![CoPilot main chat](https://samuelreichor.at/images/bitmap/craft-co-pilot-chat.png) ## Entry Slideout When editing an entry, click the **CoPilot** button in the toolbar to open a chat slideout. The slideout automatically loads the current entry as context, so the agent knows exactly what you're working on. Conversations started from the slideout are linked to that specific entry. :video-player{alt="CoPilot entry slideout" src="https://samuelreichor.at/videos/craft-co-pilot-entry-slideout.mp4"} ## Chat Features The chat interface offers several features to help you work efficiently with the agent. Here's an overview of the available options. ### Add Context You can attach entries, assets, or files to your message to give the agent additional context. Click the attachment button next to the input field to browse and select content. This is useful when you want the agent to reference or compare specific entries. :video-player{alt="CoPilot add context" src="https://samuelreichor.at/videos/craft-co-pilot-context.mp4"} ### Execution Mode The execution mode controls how the agent handles write operations: - **Supervised** (default): The agent asks for your approval before making any changes. Recommended for production environments. - **Autonomous** : The agent executes all operations without asking for confirmation. Useful for development or repetitive bulk tasks. You can switch between modes at any time using the dropdown in the chat input area. Admins can restrict this per user group via [Permissions](https://samuelreichor.at/libraries/craft-co-pilot/usage/permissions). ::alert{variant="warning"} In autonomous mode, the agent will create, update, and publish entries without confirmation. Use with caution. :: ### Multiple Providers If you've configured more than one AI provider, you can switch between them in the chat header. Each provider has different strengths – check the [AI Providers](https://samuelreichor.at/libraries/craft-co-pilot/get-started/ai-provider) overview for a comparison. ### Model Selection Within each provider, you can choose a specific model. Smaller models are faster and cheaper, while larger models produce higher quality results. The model can be changed per conversation via the chat header. ### Commands Commands are predefined prompts that you can trigger by typing `/` in the chat input. They provide shortcuts for common tasks and workflows. Some commands accept a parameter to make them more flexible. A parameter can be an entry, asset, file, or free text that gets injected into the command's prompt. For example, a `/translate` command could ask you to select an entry, then automatically translate it. :video-player{alt="CoPilot commands" src="https://samuelreichor.at/videos/craft-co-pilot-commands.mp4"} CoPilot ships without built-in commands, but your team can register custom commands tailored to your content workflows. See the [Custom Commands](https://samuelreichor.at/libraries/craft-co-pilot/developers/custom-commands) guide for instructions on how to create your own. ### Tools Tools are the actions the agent can perform on your behalf. They include searching entries, reading content, creating and updating entries, managing assets and categories, and more. ![CoPilot tools](https://samuelreichor.at/images/bitmap/craft-co-pilot-tool-usage.png) For a full list of available tools and what each one does, see the [Tools](https://samuelreichor.at/libraries/craft-co-pilot/usage/tools) overview. Developers can also extend the agent with custom tools – see the [Custom Tools](https://samuelreichor.at/libraries/craft-co-pilot/developers/custom-tools) guide. # Brand Voice Brand Voice lets you define how the AI writes content. You can set guidelines for tone, terminology, forbidden words, and language-specific instructions. These settings are applied per site, so each site can have its own voice. You can manage Brand Voice in the control panel under **CoPilot → Brand Voice**. ![CoPilot brand voice overview](https://samuelreichor.at/images/bitmap/craft-co-pilot-brand-voice.png) ## Voice & Style Guidelines Describe your brand's tone and writing style. This is the main instruction the AI follows when generating or editing content. Be as specific as you like — the more detail you provide, the more consistent the output. **Examples:** - "Write in a friendly, professional tone. Use short sentences. Avoid jargon." - "Our voice is bold and direct. We use active voice and speak to the reader as 'you'." - "Keep it casual but informative. Think blog post, not white paper." ## Glossary Define terms and their correct usage. The AI will use these exact terms when writing content instead of coming up with its own variations. **Examples:** - "CoPilot (not Co-Pilot, Copilot, or co-pilot)" - "Craft CMS (not CraftCMS or just Craft)" - "content editor (not admin, user, or backend user)" ## Forbidden Words List words or phrases the AI should never use, along with suggested alternatives. This is useful for avoiding off-brand language, outdated terms, or competitor names. **Examples:** - "cheap → affordable" - "simple → straightforward" - "users → customers" ## Language Instructions Add language-specific writing rules. This is especially useful for multi-site setups where each site targets a different language or region. **Examples:** - "Use formal German (Sie, not du)." - "Use British English spelling (colour, organisation)." - "For the French site, use inclusive writing." ## Per-Site Configuration Brand Voice settings are stored per site. If you run a multi-site setup, switch between sites using the site switcher in the Brand Voice editor to configure each site individually. ::alert{variant="note"} If no Brand Voice is configured for a site, the AI will use its default writing style without any specific guidelines. :: # Tools Tools are the actions the agent performs on your behalf. When you ask the agent to do something like "create a blog post" or "translate this entry" it uses one or more tools behind the scenes. You can see which tools are being called in the chat as they execute. ![CoPilot tools in action](https://samuelreichor.at/images/bitmap/craft-co-pilot-tool-usage.png) ## Discovery & Schema These tools help the agent understand your content structure before making changes. | Tool | Description | | --------------------- | -------------------------------------------------------------------------------------------------------------- | | listSections | Lists all sections the current user can access, with entry type handles. | | listSites | Lists all configured sites with their handles, names, and languages. | | describeSection | Returns field definitions, value formats, and hints for a section. Called before creating or updating entries. | | describeEntryType | Returns full field definitions for a specific entry type or Matrix block type. | | describeCategoryGroup | Returns field definitions for a category group. | | describeVolume | Returns field definitions for an asset volume. | ## Search Search tools let the agent find existing content. They return summaries with IDs that can be used in create or update operations. | Tool | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | searchEntries | Searches or lists entries in allowed sections. Can filter by section, status, author, and site. | | searchAssets | Searches for assets (images, files) across allowed volumes. | | searchCategories | Searches for categories by title. | | searchTags | Searches for tags by title. | | searchUsers | Searches for users by name or email. | | searchFormieForms | Searches Formie forms by title. Returns form handles. Only available when [Formie](https://verbb.io/craft-plugins/formie){rel=""nofollow""} is installed. | ## Read Read tools retrieve detailed information about specific content. | Tool | Description | | ----------- | ----------------------------------------------------------------------------------------------------------------------- | | readEntry | Reads a single entry. Summary mode shows metadata and which fields are filled. Full mode returns complete field values. | | readEntries | Batch-reads multiple entries by ID. Use instead of reading entries one by one. | | readAsset | Reads asset metadata and URL. | ## Create | Tool | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | createEntry | Creates a new entry in a section. Save behavior depends on your [configuration](https://samuelreichor.at/libraries/craft-co-pilot/get-started/configuration#elementcreationbehavior). | | createCategory | Creates a new category in a category group. | ## Update | Tool | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | updateEntry | Updates one or more fields of an existing entry in a single save. Save behavior depends on your [configuration](https://samuelreichor.at/libraries/craft-co-pilot/get-started/configuration#elementupdatebehavior). | | updateAsset | Updates asset metadata like title, alt text, and custom fields. | | updateCategory | Updates an existing category's title, slug, and custom fields. | | publishEntry | Publishes an entry by enabling it and saving directly. Only used when explicitly asked to publish or go live. | ## How Tools Work Together The agent typically follows this workflow when making content changes: 1. **Discover**: listSections to find the right section 2. **Describe**: describeSection to learn the field structure 3. **Search/Read**: searchEntries or readEntry to find or inspect existing content 4. **Write**: createEntry or updateEntry to make changes 5. **Verify**: readEntry to confirm the changes were applied correctly ::alert{variant="note"} All tools respect the [access control](https://samuelreichor.at/libraries/craft-co-pilot/usage/permissions) settings. If a section is set to read-only, the agent can read but not modify entries in that section. :: ## Custom Tools Developers can extend the agent with custom tools to add new capabilities. See the [Custom Tools](https://samuelreichor.at/libraries/craft-co-pilot/developers/custom-tools) guide for a full walkthrough. # Permissions CoPilot uses a two-layer permission system. **Both layers must allow an action** for it to succeed. 1. **Plugin settings**: Control what the agent can access (sections, volumes, categories) 2. **Native user permissions**: Control what the logged-in user can do ## How They Work Together When the agent tries to read or write content, both layers are checked: | Plugin setting | Craft user permission | Result | | -------------- | --------------------- | --------- | | ReadWrite | User has view + save | Allowed | | ReadWrite | User has view only | Read only | | ReadOnly | User has view + save | Read only | | Blocked | User has view + save | Denied | | ReadWrite | User has no access | Denied | In short: the agent can never do more than what **both** the plugin settings and the user's Craft permissions allow. ::alert{variant="note"} Admin users bypass Craft's native permission checks but are still restricted by the plugin's access settings. If a section is set to read-only or blocked in the plugin, even admins cannot write to it through the agent. :: ## Plugin Access Settings These are configured in **Settings → CoPilot → Permissions** and control what the agent is allowed to do per section, volume, and category group. :video-player{alt="Craft CoPilot permission settings" src="https://samuelreichor.at/videos/craft-co-pilot-permissions.mp4"} ### Blocked Element Types You can block entire element types from the agent. By default, Commerce Orders are blocked. This is useful for preventing the agent from interacting with sensitive element types entirely. ```php // config/co-pilot.php return [ '*' => [ 'blockedElementTypes' => [ 'craft\commerce\elements\Order', ], ], ] ``` Blocked element types are checked first, they take priority over all other access settings and user permissions. ## Craft User Permissions CoPilot registers its own set of user permissions in Craft. You can assign them to user groups under **Settings → Users → User Groups**. ![CoPilot user permissions](https://samuelreichor.at/images/bitmap/craft-co-pilot-user-permissions.png) With that you can restrict users to only see there own chats or prevent them to edit the brand voice. # Audit Log The Audit Log records tool call the agent makes, reads, creates, and updates. It gives you full traceability of what the agent did, when, and for which entry. You can access the Audit Log in the control panel under **CoPilot → Audit Log**. ![CoPilot audit log overview](https://samuelreichor.at/images/bitmap/craft-co-pilot-audit-log.png){style="width: 100%;border-radius:6px"} ## What Gets Logged Every tool execution is logged automatically with the following information: - **User**: Who triggered the action - **Tool**: Which tool was called (e.g. updateEntry, createEntry, searchEntries) - **Action**: The type of operation (read, create, update) - **Element**: The affected element with a direct link to the editor - **Conversation**: Link back to the conversation where the action happened - **Status**: Whether the action succeeded or was denied - **Date**: When the action was performed - **Diff**: What has changed during update operations ## Field-Level Diffs For update operations, the audit log tracks exactly which fields changed and shows a before/after comparison. This makes it easy to review what the agent modified without having to check the element itself. ![CoPilot audit log diff](https://samuelreichor.at/images/bitmap/craft-co-pilot-audit-log-diff.png) ## Data Retention Old audit log entries are automatically cleaned up via Craft's garbage collection. By default, entries are kept for 30 days. You can adjust this in the [configuration](https://samuelreichor.at/libraries/craft-co-pilot/get-started/configuration#auditlogretentiondays). ::alert{variant="warning"} Cleanup is irreversible. Once audit log entries are deleted, they cannot be recovered. :: # Third Party Fields CoPilot supports all native Craft field types out of the box. For third-party fields, dedicated transformers are needed to teach the agent how to read and write these fields correctly. ## Supported Plugins | Plugin | Status | | ----------------------------------------------------------------------------- | --------- | | [CKEditor](https://plugins.craftcms.com/ckeditor){rel=""nofollow""} | Supported | | [Redactor](https://plugins.craftcms.com/redactor){rel=""nofollow""} | Supported | | [LLMify](https://plugins.craftcms.com/llmify){rel=""nofollow""} | Supported | | [Formie](https://verbb.io/craft-plugins/formie){rel=""nofollow""} | Supported | ::field-group :::field{name="Formie"} When Formie is installed, CoPilot automatically registers a `searchFormieForms` tool that lets the agent find forms by title and set them on Formie fields. ::: :: ## Coming Soon We're working on built-in support for these plugins: | Plugin | Status | | ----------------------------------------------------------------------------- | -------------- | | [Freeform](https://solspace.com/craft/freeform){rel=""nofollow""} | In development | | [SEOmatic](https://plugins.craftcms.com/seomatic){rel=""nofollow""} | In development | | [SEOMate](https://plugins.craftcms.com/seomate){rel=""nofollow""} | Planned | | [Hyper](https://verbb.io/craft-plugins/hyper){rel=""nofollow""} | Planned | ## Custom Field Support If you use a field type that isn't supported yet, you can add your own transformer. See the [Custom Fields](https://samuelreichor.at/libraries/craft-co-pilot/developers/custom-fields) guide for a walkthrough. Plugin developers can also submit a feature request on [GitHub](https://github.com/samuelreichor/craft-co-pilot/issues){rel=""nofollow""} to add native support for their field types. # Custom Commands Commands are predefined prompts triggered via `/` in the chat input. CoPilot ships without built-in commands, but you can register your own via events. ## Example A `/translate` command that lets the user pick an entry and translates it to all sites: ::code-tree{default-value="modules/mymodule/commands/TranslateCommand.php"} ```php [modules/mymodule/commands/TranslateCommand.php] 'entry', 'label' => 'Select entry to translate']; } } ``` ```php [modules/mymodule/MyModule.php] commands[] = new TranslateCommand(); }, ); } } ``` :: ## Parameter Types `getParam()` returns `null` for no parameter, or an array with `type` and `label`: | Type | Resolves to | UI | | ------- | ------------ | --------------------- | | `entry` | Entry ID | Entry selection modal | | `asset` | Asset ID | Asset selection modal | | `file` | File content | File upload dialog | | `text` | Free text | Text input field | The resolved value replaces `{paramName}` placeholders in the prompt. Return `null` from `getParam()` if the command needs no parameter — these work best from the entry slideout, where the current entry is already loaded as context. # Custom Elements Element transformers control how element types are serialized for the AI context. CoPilot ships with transformers for Entries and Assets. For third-party element types (e.g. Commerce Products), register your own via events. ## Example This is the built-in `AssetTransformer`, it serializes native properties and custom fields: ::code-tree{default-value="src/transformers/elements/AssetTransformer.php"} ```php [src/transformers/elements/AssetTransformer.php] 'asset', 'id' => $element->id, 'filename' => $element->filename, 'url' => $element->url, 'alt' => $element->alt ?? '', 'kind' => $element->kind, 'size' => $element->size, 'width' => $element->width, 'height' => $element->height, ]; $fields = $this->serializeCustomFields($element, $depth); if ($fields !== []) { $data['fields'] = $fields; } return $data; } public function getElementTypeLabel(): string { return 'Asset'; } } ``` ```php [modules/mymodule/MyModule.php] transformers[] = new ProductTransformer(); }, ); } } ``` :: ## ElementTransformerInterface | Method | Returns | Description | | ------------------------------ | ------------ | ------------------------------------------------------------------------------------- | | `getSupportedElementClasses()` | `string[]` | FQCNs of element classes this transformer handles. | | `serializeElement()` | `array|null` | Serialize the element to a JSON-safe array for the AI context. Return `null` to skip. | | `getElementTypeLabel()` | `string` | Human-readable label used in schema context (e.g. "Product"). | ## SerializeFallbackTrait Use `SerializeFallbackTrait` to get access to `serializeCustomFields()`, it resolves all custom fields from the element's field layout and serializes them using the registered field transformers. This avoids duplicating the field serialization logic in every element transformer. # Custom Fields Field transformers tell the agent how to describe, read, and write a field type. CoPilot covers all native Craft fields. For third-party fields, register your own transformer via events. ## Example A transformer for [Formie](https://verbb.io/craft-plugins/formie){rel=""nofollow""} form selection fields. Combined with a [custom tool](https://samuelreichor.at/libraries/craft-co-pilot/developers/custom-tools) like `searchFormieForms`, the agent can find a form by name and set it on an entry. ::code-tree --- default-value: modules/mymodule/transformers/FormieFieldTransformer.php --- ```php [modules/mymodule/transformers/FormieFieldTransformer.php] one() : $value; if ($form === null) { return null; } return [ 'id' => $form->id, 'title' => $form->title, 'handle' => $form->handle, ]; } public function normalizeValue( FieldInterface $field, mixed $value, ?Element $element = null, ): mixed { // Accept a handle string and resolve it to a form ID if (is_string($value)) { $form = \verbb\formie\elements\Form::find() ->handle($value) ->one(); return $form ? [$form->id] : null; } return null; } } ``` ```php [modules/mymodule/MyModule.php] transformers[] = new FormieFieldTransformer(); }, ); } } ``` :: ## FieldTransformerInterface | Method | Description | | ---------------------------- | ------------------------------------------------------------------------------------------------------------ | | `getSupportedFieldClasses()` | Return FQCNs of field classes this transformer handles. | | `matchesField()` | Custom matching logic. Return `true` to claim, `false` to skip, `null` to fall through to class matching. | | `describeField()` | Enrich the field info array with `valueFormat`, `hint`, and other metadata for the AI. | | `serializeValue()` | Convert a field value into a JSON-serializable format for the AI context. | | `normalizeValue()` | Convert an AI-provided value back into Craft's expected format. Return `null` if no normalization is needed. | ## Matching Transformers are checked in order. For each field, the registry calls `matchesField()` first. If it returns `null`, class matching via `getSupportedFieldClasses()` is used as fallback. Event-registered transformers are checked **before** built-in ones, so you can override default behavior for any field type. # Custom Providers CoPilot ships with OpenAI, Anthropic, and Gemini providers. You can add your own by implementing `ProviderInterface` and registering it via events. ## Registration ```php use samuelreichor\coPilot\events\RegisterProvidersEvent; use samuelreichor\coPilot\services\ProviderService; use yii\base\Event; Event::on( ProviderService::class, ProviderService::EVENT_REGISTER_PROVIDERS, function(RegisterProvidersEvent $event) { $event->providers['myProvider'] = new MyCustomProvider(); }, ); ``` The key in `$event->providers` is the provider handle used in configuration. ## ProviderInterface Your provider must implement `samuelreichor\coPilot\providers\ProviderInterface`. The interface requires methods for configuration, model selection, chat completion, and streaming. Use one of the built-in providers as a reference implementation: - [`OpenAIProvider`](https://github.com/samuelreichor/craft-co-pilot/blob/main/src/providers/OpenAIProvider.php){rel=""nofollow""} - [`AnthropicProvider`](https://github.com/samuelreichor/craft-co-pilot/blob/main/src/providers/AnthropicProvider.php){rel=""nofollow""} - [`GeminiProvider`](https://github.com/samuelreichor/craft-co-pilot/blob/main/src/providers/GeminiProvider.php){rel=""nofollow""} # Custom Tools Tools are actions the agent can call during a conversation. CoPilot ships with built-in tools for reading, searching, and editing content. You can add your own via events. ## Example A `searchFormieForms` tool that lets the agent find Formie form handles: ::code-tree{default-value="modules/mymodule/tools/SearchFormieFormsTool.php"} ```php [modules/mymodule/tools/SearchFormieFormsTool.php] 'object', 'properties' => [ 'query' => [ 'type' => 'string', 'description' => 'Optional search query to filter forms by title.', ], ], 'required' => [], ]; } public function execute(array $arguments): array { if (!PluginHelper::isPluginInstalledAndEnabled('formie')) { return ['error' => 'Formie plugin is not installed.']; } $formsQuery = \verbb\formie\elements\Form::find(); $query = $arguments['query'] ?? null; if ($query) { $formsQuery->title('*' . $query . '*'); } $forms = $formsQuery->all(); if (empty($forms)) { return ['results' => [], 'message' => 'No forms found.']; } $results = array_map(fn($form) => [ 'id' => $form->id, 'title' => $form->title, 'handle' => $form->handle, ], $forms); return [ 'results' => $results, 'total' => count($results), ]; } } ``` ```php [modules/mymodule/MyModule.php] tools[] = new SearchFormieFormsTool(); }, ); } } ``` :: ## ToolInterface | Method | Returns | Description | | ------------------ | ------------- | ----------------------------------------------------------- | | `getName()` | `string` | Unique tool name for AI function calling. | | `getDescription()` | `string` | Description the AI uses to decide when to call this tool. | | `getParameters()` | `array` | JSON Schema defining the tool's input parameters. | | `getLabel()` | `string` | Human-readable label for the audit log. | | `getAction()` | `AuditAction` | Audit action type: `Read`, `Search`, `Create`, or `Update`. | | `execute()` | `array` | Runs the tool and returns a result array. | The `execute()` return value is sent directly to the AI as the tool result. Return an `error` key to signal failure. # System Prompt The system prompt controls how the agent behaves — its tone, rules, and domain knowledge. You can append custom sections via the `EVENT_BUILD_PROMPT` event. ## Example Add project-specific workflow rules that the agent should always follow: ::code-tree{default-value="modules/mymodule/MyModule.php"} ```php [modules/mymodule/MyModule.php] sections[] = implode("\n", [ '## Content Workflow Rules', 'Entries in the "products" section are synced to Shopify — never change their slug or status.', 'Every blog post must have at least one category and a featured image before publishing.', 'When creating entries in the "legal" section, always set the "reviewRequired" lightswitch to true.', ]); }, ); } } ``` :: ## How It Works The `$event->sections` array contains all prompt sections as strings. They are joined with double newlines to form the final system prompt. Your sections are appended after the built-in ones. Use Markdown headings (`##`) to structure your sections — this helps the AI parse and follow the instructions. # CoPilot :video-player{alt="CoPilot demo" src="https://samuelreichor.at/videos/craft-co-pilot-chat-demo.mp4"} An AI agent that lives in your Craft CMS control panel. It reads your content structure, writes in your brand voice, and handles everything from single field edits to full multi-site translations. ## Features - **AI Chat in the Control Panel**: A full chat interface and an entry slideout - **Read, Write & Publish**: Create entries, fill fields, update content, and publish it directly - **Multi-Site Translation**: Translate entries across sites and languages with automatic propagation handling - **Multi-Provider Support**: Use [Langdock](https://langdock.com){rel=""nofollow""}, Anthropic, OpenAI, or Google Gemini - **Granular Permissions**: Control read, write, or block access per section, volume, and category group - **Brand Voice & Glossary**: Define tone, terminology, and forbidden words to keep content on brand - **Custom Commands & Tools**: Register your own slash commands and tools via events to extend the agent with project-specific capabilities - **Audit Log**: Full traceability of every read, create, and update the agent performs (with field-level diffs) - **Web Search**: Let the agent browse the web to research and enrich your content ## Why CoPilot? If you've worked on a Craft project with 10+ languages, you know the drill. Creating a single entry means clicking through dozens of fields. Translating it to five languages means doing that five more times. Keeping everything consistent across sites is tedious, repetitive work that nobody enjoys. We've been building and maintaining large Craft sites for years, and this has always been the part that slows teams down the most. To that point that developing the website from scatch was faster than the actual conent creation. CoPilot started as an experiment: what if an AI agent actually understood Craft's content model? Not a generic chatbot bolted onto the CP, but an agent that knows your sections, reads your field layouts, respects your permissions, and writes content the way your brand voice demands. After weeks of real-world testing with complex multi-site setups, we're confident it delivers on that promise. But we'll let you be the judge and we are looking forward for Feedback! ## Getting Started ::card-group :::card --- title: Installation to: https://samuelreichor.at/libraries/craft-co-pilot/get-started/installation --- Install via Composer and configure your AI provider. ::: :::card --- title: AI Providers to: https://samuelreichor.at/libraries/craft-co-pilot/get-started/ai-provider --- Compare providers and pick the right model. ::: :::card --- title: Configuration to: https://samuelreichor.at/libraries/craft-co-pilot/get-started/configuration --- Permissions, agent behavior, and content settings. ::: :: # @query-api/js ## Features - **Support for Main Element Types:** Query addresses, assets, entries and users. - **Helper for Preview Mode:** Easily add preview mode to your headless setup. - **Typesafe:** Built with typescript in mind. - **Easy Adaptable:** You can easily build your own custom wrapper with that core logic. ## Examples Want to see how it works? ```ts [app.ts] import { buildCraftQueryUrl } from '@query-api/js'; // Build URL for fetching a single address const url = buildCraftQueryUrl('entries') .id(1) .status('active') .siteId(1) .buildBaseUrl('one'); // Result: /v1/api/queryApi/customQuery?elementType=addresses&id=1&status=active&siteId=1&one=1 ``` It is as simple as that. 🚀 ## Further Resources - [Craft Query API](https://samuelreichor.at/libraries/craft-query-api): A Craft CMS Plugin, that powers this stuff. - [Vue SDK](https://samuelreichor.at/libraries/vue-craftcms): A package to use the query builder in Vue. - [Nuxt SDK](https://samuelreichor.at/libraries/nuxt-craftcms): A package to use the query builder in Nuxt. - [React SDK](https://samuelreichor.at/libraries/query-api-react): A package to use the query builder in React. - [Next SDK](https://samuelreichor.at/libraries/query-api-next): A package to use the query builder in Next.js. # Installation ## Requirements - The [Craft Query API](https://samuelreichor.at/libraries/craft-query-api) plugin must be installed and configured. - Node > 20 ## Install ```bash npm install @query-api/js ``` Boom, finished. 🚀 # Methods This API allows building urls for Craft CMS elements (addresses, assets, entries, and users) by providing a querybuilder. ## Supported Element Types Each element type has its own set of available methods. This ensures precise control and great type safty. - Addresses - Assets - Entries - Users ::alert{variant="note"} Categories, Tags and Globals are not supported because they may be deprecated in the future. :: ## Special Methods These are methods available for all element types and are not native in Craft CMS. ::alert{variant="tip"} When the type is defined as `number[]`, you can always include operator strings such as `not` or `and`. You can find out more about types and methodes in the [source code](https://github.com/samuelreichor/query-api/blob/main/packages/js/src/index.ts){rel=""nofollow""}. :: | Method | Description | Type | | ----------------- | -------------------------------------------------------------------------------------------------- | ---------------------- | | `fields` | Select specific fields to retrieve. | `string` or `string[]` | | `includeAllEntry` | Whether to include the full data of entries or just the minimal fields (title, URI, ID, and slug). | `boolean` | | `buildBaseUrl` | Build the url for one() or all() | `one` or `all` | ::alert{variant="warning"} If you use the `includeAllEntry` param, be sure that you don't have circular entry relations. This would end up in an endless loop. :: ## Address Methods | Method | Description | Type | | -------------- | ------------------------------------- | ---------------------- | | `addressLine1` | Filter by first line of address. | `string` | | `addressLine2` | Filter by second line of address. | `string` | | `addressLine3` | Filter by third line of address. | `string` | | `fixedOrder` | Maintain order of `id()`. | `boolean` | | `fullName` | Filter by full name. | `string` | | `id` | Filter by unique identifier. | `number` or `number[]` | | `limit` | Limit the number of results returned. | `number` | | `locality` | Filter by city or locality. | `string` | | `offset` | Set an offset for pagination. | `number` | | `orderBy` | Define sorting order. | `string` | | `organization` | Filter by organization name. | `string` | | `search` | Search by string. | `string` | --- ## Asset Methods | Method | Description | Type | | ------------ | ------------------------------------- | ---------------------- | | `filename` | Filter by file name. | `string` | | `fixedOrder` | Maintain order of `id()`. | `boolean` | | `id` | Filter by unique identifier. | `number` or `number[]` | | `kind` | Filter by asset type (e.g., "image"). | `string` | | `limit` | Limit the number of results returned. | `number` | | `offset` | Set an offset for pagination. | `number` | | `orderBy` | Define sorting order. | `string` | | `search` | Search by string. | `string` | | `site` | Filter by site handle. | `string` | | `siteId` | Filter by site ID. | `number` or `number[]` | | `volume` | Filter by asset volume. | `string` | --- ## Entry Methods | Method | Description | Type | | ----------------- | ------------------------------------- | --------------------------------------------- | | `fixedOrder` | Maintain order of `id()`. | `boolean` | | `id` | Filter by unique identifier. | `number` or `number[]` | | `level` | Filter by the level. | `number` or `number[]` | | `limit` | Limit the number of results returned. | `number` | | `offset` | Set an offset for pagination. | `number` | | `orderBy` | Define sorting order. | `string` | | `postDate` | Filter by post date. | `string` | | `relatedTo` | Get related elements. | `RelatedToParam` (use ids instead of entries) | | `notRelatedTo` | Get NOT related elements. | `RelatedToParam` (use ids instead of entries) | | `andRelatedTo` | Get AND related elements. | `RelatedToParam` (use ids instead of entries) | | `andNotRelatedTo` | Get AND NOT related elements. | `RelatedToParam` (use ids instead of entries) | | `search` | Search by string. | `string` | | `section` | Filter by section handle. | `string` or `string[]` | | `sectionId` | Filter by section id. | `number` or `number[]` | | `site` | Filter by site handle. | `string` | | `siteId` | Filter by site ID. | `number` or `number[]` | | `slug` | Filter by entry slug. | `string` | | `status` | Filter by status. | `EntryStatusString` or `EntryStatusString[]` | | `type` | Filter by entryType. | `string` or `string[]` | | `uri` | Filter by entry URI. | `string` or `string[]` | --- ### User Methods | Method | Description | Type | | ------------ | ------------------------------------- | ------------------------------------------ | | `admin` | Filter if user is admin. | | | `email` | Filter by email address. | `string` | | `fixedOrder` | Maintain order of `id()`. | `boolean` | | `fullName` | Filter by full name. | `string` | | `group` | Filter by user group. | `string` or `string[]` | | `groupId` | Filter by user group ID. | `number` or `number[]` | | `hasPhoto` | Filter users with profile photos. | `boolean` | | `id` | Filter by unique identifier. | `number` or `number[]` | | `limit` | Limit the number of results returned. | `number` | | `offset` | Set an offset for pagination. | `number` | | `orderBy` | Define sorting order. | `string` | | `search` | Search by string. | `string` | | `status` | Filter by status. | `UserStatusString` or `UserStatusString[]` | # Basic Usage ## `buildCraftQueryUrl` `buildCraftQueryUrl` is the core function for building query URLs. It takes an `elementType` as an argument and allows you to chain various methods to specify query parameters. This function makes it easy to generate URLs to fetch specific data from Craft CMS. ### Example Usage ```typescript import { buildCraftQueryUrl } from '@query-api/js'; // Build URL for fetching a single address const url = buildCraftQueryUrl('addresses').id(1).buildBaseUrl('one'); // Result: /v1/api/queryApi/customQuery?elementType=addresses&id=1&one=1 // Build URL for fetching a single asset const url = buildCraftQueryUrl('assets').id(1).buildBaseUrl('one'); // Result: /v1/api/queryApi/customQuery?elementType=assets&id=1&one=1 // Build URL for fetching a single entry const url = buildCraftQueryUrl('entries').id(1).buildBaseUrl('one'); // Result: /v1/api/queryApi/customQuery?elementType=entries&id=1&one=1 // Build URL for fetching a single user const url = buildCraftQueryUrl('users').id(1).buildBaseUrl('one'); // Result: /v1/api/queryApi/customQuery?elementType=users&id=1&one=1 ``` ::alert{variant="note"} For a full list of available methods, refer to the [API documentation](https://samuelreichor.at/libraries/js-craftcms-api/methods). :: You can use the generated URL to make a fetch request to your Craft CMS backend. Just be sure to add a `Authorization` Header with a valid Bearer Token to your request. ## Preview Mode `buildCraftQueryUrl()` automatically handles preview mode by injecting the necessary token into the URL. This feature ensures that you can easily preview content without additional setup. # Advanced Usage ## Custom Wrapper Example The following example demonstrates how to build a custom wrapper for `buildCraftQueryUrl()` in Vue. This wrapper, `useCraftUrlBuilder`, adds custom methods and handles URLs more flexibly. ```ts [useCraftUrlBuilder.ts] import { buildCraftQueryUrl } from '@query-api/js'; import type { ElementType, ExecutionMethod } from '@query-api/js'; export function useCraftUrlBuilder(elementType: T) { const queryBuilder = buildCraftQueryUrl(elementType); // Initialize the core builder const baseUrl = '' // primary site url of your craft system const debug = false return { ...queryBuilder, // Custom method to build the full URL buildUrl(execOpt: ExecutionMethod) { const queryUrl = queryBuilder.buildBaseUrl(execOpt); const url = `${baseUrl}${queryUrl}`; if (debug) { console.log('The built URL is: ' + url); } return url; }, }; } ``` ### Explanation - `queryBuilder`: Uses `buildCraftQueryUrl()` to initialize the core query builder. - `baseUrl` and `debug`: These values should come from a global config or env file. - `buildUrl(execOpt: ExecutionMethod)`: Extends the base query builder with a `buildUrl` method, which generates the full URL by appending `baseUrl` and, if `debug` is enabled, logs the URL to the console. # @query-api/vue ## Features - **Craft CMS query url builder:** Easily build urls for the Craft Query API plugin directly from Vue, enabling flexible, real-time data retrieval from Craft CMS - **Built in Helper Components:** Connect your data directly with your Vue components, to speed up development. - **Get Only the Data You Need:** Avoid overfetching by using a custom function in the query builder to select only the fields you require. - **Pretty Json:** Json Transformer are in place to prettify the response. - **Support for main Element Types:** Query addresses, assets, entries and users. ## Examples Want to see how it works? ```ts [app.vue] const queryUrl = useCraftUrlBuilder('entries') .id(1) .status('active') .siteId(1) .buildUrl('one') ``` It is as simple as that. 🚀 The response will be the url that you can use to fetch your data. ## Further Resources - [Craft Query API](https://samuelreichor.at/libraries/craft-query-api): A Craft CMS Plugin, that powers this great stuff. - [Nuxt SDK](https://samuelreichor.at/libraries/nuxt-craftcms): A package to use the query builder in Nuxt. - [JS SDK](https://samuelreichor.at/libraries/js-craftcms-api): Foundation to build a query builder with your preferred JS framework. # Introduction The `@query-api/vue` package provides a powerful query builder for Vue, enabling you to build URLs for fetching data from Craft CMS in a way similar to Twig queries. ## Requirements - The [Craft Query API](https://samuelreichor.at/libraries/craft-query-api) plugin must be installed and properly configured in Craft CMS. - Vue 3 is required. ## Supported Element Types The library supports building urls for the following Craft CMS element types: - Addresses - Assets - Entries - Users ## Need Help? If you encounter bugs or have feature requests, please [submit an issue](https://github.com/samuelreichor/query-api/issues/new){rel=""nofollow""}. Your feedback helps improve the library! # Installation ## Requirements - The [Craft Query API](https://samuelreichor.at/libraries/craft-query-api) plugin must be installed and properly configured in Craft CMS. - Vue 3 is required. ## Install ```bash npm install @query-api/vue ``` You can register `@query-api/vue` package with the `craftcms` property in your `main.ts` file. ```ts [main.ts] import { CraftCms } from '@query-api/vue'; import { createApp } from 'vue'; import App from './App.vue'; const app = createApp(App); /* const defaults = { baseUrl: '', // Required authToken: '', // Required registerComponents: true, debug: false, enableEntryTypeMapping: true, siteMap: [], }; */ // A valid config could look like that app.use(CraftCms, { baseUrl: 'https://example.ddev.site', authToken: 'Bearer your-auth-token', registerComponents: true, debug: false, enableEntryTypeMapping: true, siteMap: [ { handle: 'en', origin: 'http://localhost:3000', id: 1, }, { handle: 'de', origin: 'http://localhost:3000/de', id: 2, }, ], }); app.mount('#app'); ``` - **baseUrl:** Refers to the PRIMARY\_SITE\_URL of Craft CMS, without a trailing slash. - **authToken:** Provide a valid access token. The token should look like that `Bearer youlookgood`. - **debug:** Enables debug mode and log built urls. - **registerComponents:** Globally register all components. - **enableEntryTypeMapping:** This allows the `CraftPage` component to automatically detect your entries based on their section handle and entry type. - **siteMap:** Define an array of sites to enable multisite support. Each site object must include a handle and an origin. Boom, finished. 🚀 # Basic Usage Let's dive in and use that thing! This package offers flexible methods for integrating Craft CMS data into your Vue application. ## Build Query URLs This package does not include direct fetching from your Craft CMS backend to keep things flexible for you. Instead, you can use `useCraftUrlBuilder()` to create custom query URLs and implement your own fetch function as needed. You can build an url like that: ```ts [app.vue] const queryUrl = useCraftUrlBuilder('entries') .id(1) .status('active') .buildUrl('one') //result = https://your-primary-site-url/v1/api/queryApi/customQuery?elementType=entries&id=1&status=active&one=1 ``` ::alert{variant="tip"} You can find detailed instructions on how to [build query urls](https://samuelreichor.at/libraries/vue-craftcms/usage/build-query-urls) here. :: ## Connect Components You can map Craft CMS section handles and matrix block handles to Vue components, enabling the module to automatically render Vue pages and Vue blocks based on your content structure. This method simplifies data rendering by letting the package handle the content logic. ::alert{variant="tip"} You can find detailed instructions on how to [connect your components](https://samuelreichor.at/libraries/vue-craftcms/usage/connect-components) here. :: # Build Query URLs In this guide, you’ll learn to use the `useCraftUrlBuilder()` composable to build custom query URLs for your Craft CMS backend. This approach offers precise control over the data you retrieve, allowing you to specify fields and configure queries to suit your needs. ## Build an URL The `useCraftUrlBuilder()` composable lets you construct a query URL step-by-step. Here’s how to set up a query URL to fetch a list of related news articles: ```ts const queryUrl = useCraftUrlBuilder('entries') .section('news') .fields(['title']) .limit(3) .buildUrl('one') //result = https://your-primary-site-url/v1/api/queryApi/customQuery?elementType=entries&id=1&status=active&one=1 ``` ::alert{variant="tip"} Find out more about the available query methodes in the [useCraftQuery()](https://samuelreichor.at/libraries/vue-craftcms/composables/use-craft-url-builder) docs. :: ## Fetch with Query URLs After building the query URL, you can use it in a fetch function to retrieve data: For example: ```ts const queryUrl = useCraftUrlBuilder('entries') .section('news') .fields(['title']) .limit(3) .buildUrl('one') const data = ref(await fetchData(queryUrl)); ``` Here is a small example fetch function with some error handling in place. ```ts [~/composables/useCraftFetch.ts] export async function useCraftFetch(url: string) { const { authToken } = useCraft() const response = await fetch(url, { headers: { Authorization: authToken, } }); if (!response.ok) { throw new Error(`Failed to fetch data from ${url}: ${response.statusText}`); } return await response.json(); } ``` # Connect Components This guide explains the steps to connect your Vue components with data from Craft CMS. We’ll create a custom fetch function, mapping object for components, query data, and use the `` and `` components to display content dynamically. ## Custom Fetch Function Before we can show data we should build a custom fetch function. For that you can make a new composable. ```ts [~/composable/useCraftFetch.ts] export async function useCraftFetch(url: string) { const { authToken } = useCraft() const response = await fetch(url, { headers: { Authorization: authToken, } }); if (!response.ok) { throw new Error(`Failed to fetch data from ${url}: ${response.statusText}`); } return await response.json(); } ``` ## Catch all route To support dynamic URLs in your Vue app, add a catch-all route by following [this guide](https://router.vuejs.org/guide/essentials/dynamic-matching.html#Catch-all-404-Not-found-Route){rel=""nofollow""}. Your `router.ts` file can look like that: ```ts [router.ts] import { createRouter, createWebHistory } from 'vue-router'; import CraftRouter from './CraftRouter.vue'; // next step export const router = createRouter({ history: createWebHistory(), routes: [{ path: '/:pathMatch(.*)*', component: CraftRouter }], }); ``` ## CraftRouter Component To handle the routing correctly you should now make a `CraftRouter.vue`. This file is there to handle the routing Logic based on the router uri. We watch if the uri changes and if so we update the uri variable. This is neccessary for the correct fetch to Craft CMS. ```vue [CraftRouter.vue] ``` ## Mapping Object Define a mapping object in the `CraftRouter.vue` file. This object connects each Craft CMS section handle to a Vue page component and each field handle to a specific Vue component. This setup allows the correct component to render based on the Craft CMS data. For example: ```vue [CraftRouter.vue] ``` ### Working with Entry Types When `enableEntryTypeMapping` is set to `true` in your [configuration in your main.ts](https://samuelreichor.at/libraries/vue-craftcms/get-started/install#install), you can link your Craft entries using the format `sectionHandle:entryTypeHandle`. If the entry type handle is `default` or matches the section handle, you don’t need to explicitly define the `:entryTypeHandle`. ```js const mapping: ContentMapping = { pages: { home: Home, // equivalent to home:default or home:home 'news:default': News, // equivalent to news or news:news 'news:reference': News, // section handle = news, entry type handle = reference }, components: { // additional components }, }; ``` ## Display Page Use the `useCraftFetch()` helper to retrieve data from Craft CMS, and display the page content using the `` component within `CraftRouter.vue`. This component automatically renders the defined pages based on the mapping configuration and the data fetched from Craft CMS. ::alert{variant="tip"} To find out more about the `` check out the [craft page docs](https://samuelreichor.at/libraries/vue-craftcms/components/craft-page). :: Here’s how your `CraftRouter.vue` looks now. ```vue [CraftRouter.vue] ``` This setup should render the correct Vue page based on your defined mapping object. To verify the data structure Craft CMS sends to your page, you can add the following code to inspect the data in `./views/home.vue`: ```vue [./views/home.vue] ``` ## Display Components To connect Matrix blocks with Vue components, use the `` component. This component will dynamically render Vue components based on the content provided from Craft CMS. Example: ```vue [./views/home.vue] ``` ::alert{variant="tip"} To find out more about the `` check out the [docs](https://samuelreichor.at/libraries/vue-craftcms/components/craft-area). :: # Example ## Playground Get a quick overview by exploring the playground in the [library](https://github.com/samuelreichor/query-api/tree/main/playgrounds/vue-app){rel=""nofollow""}. # useCraftUrlBuilder This composable provides a simple way to build urls to query from your Craft CMS Backend. It leverages [js-craftcms-api](https://samuelreichor.at/libraries/js-craftcms-api) to build query URLs. ## Element Types ```vue ``` ## Generate URL By using the `one` or the `all` as function parameters of the `buildUrl()` method you can build the url. ```ts const query = useCraftUrlBuilder('entries').section('news') // Generates a url to fetch one entry const url = query.buildUrl('one') // Generates a url to fetch all entries const url = query.buildUrl('all') ``` ## Available Methods `useCraftUrlBuilder` supports all methods from [js-craftcms-api](https://samuelreichor.at/libraries/js-craftcms-api). ::alert{variant="note"} For the full list of available methods, see the [documentation here](https://samuelreichor.at/libraries/js-craftcms-api/methods). :: In addition, the following Vue-specific methods are available: | Method | Description | Type | | ---------- | --------------- | -------------- | | `buildUrl` | Generate an url | `one` or `all` | ## Example ```ts const url = useCraftUrlBuilder('entries') .section('news') .fields(['title']) .limit(3) .buildUrl('all') ``` # useCraft This composable returns the instance of the plugin. This is useful if you need sites or the base cp url. ```vue ``` # CraftPage The `CraftPage` component renders the mapped Vue view based on your queried data and the config.pages prop. ```vue ``` ## `config` The `config` option lets you connect your Craft CMS sections and entry types to Vue components. In `pages`, use `sectionHandle:entryTypeHandle` (e.g., `news:home`) to map to a Vue page. If the entry type handle is the same as the section handle, or if it's the default entry type for that section, you can simply use the section handle as the key (e.g., home). ::alert{variant="note"} To turn off `sectionHandle:entryTypeHandle` mapping, set `enableEntryTypeMapping: false` in `craftInit`. :: In `components`, map entry types to Vue components. This is useful for things like matrix blocks. This is used in the `CraftArea` component later on. If you prefer to define the component mapping directly when using the CraftArea component you can do that as well. - **Type:** `object` - **Default:** `{ pages: {}, components: {} }` - **Example:** ```ts contentMapping: { pages: { home: Home, // Maps section home entry with entry type home to the Home component. 'news:home': News, // Maps section news entry with entry type home to the News component. }, components: { headline: Headline, // Entry type headline will be rendered with the Headline component. imageText: CraftNotImplemented, }, } ``` #### Error Pages You can also map error pages by providing special keys in the `contentMapping.pages` object. This allows you to render custom Next.js components for specific error scenarios. - `page404`: For 404 Not Found errors. - `error`: A general fallback for other errors. ## `content` The actual content data returned from a Craft CMS query, such as one created with `useCraftEntry()`. It should at least contain the `sectionHandle` and `entryType`. # CraftArea The `CraftArea` component maps field handles defined in Craft CMS to components in Nuxt / Vue defined in the `config` prop of the `CraftPage`. The `content` prop receives the actual data from your Craft CMS query, typically an array of Matrix Field data. ```vue ``` ## `content` This prop accepts an array of objects. Each Object should contain a key named `type`. The `type` represents the entry type of the entry in the matrix field. This value is used to find the correct Vue component based on the `contentMapping` defined in the `CraftPage`. ## `block-mapping` This optional prop accepts an object that contains the `type` as the key and a Vue component that you want to render for the given `type`. You can also define this mapping where you include the `CraftPage` component. # CraftNotImplemented The `CraftNotImplemented` componentis a small development helper with the following purposes: - Display Unimplemented Block Types: Shows a message indicating any block type that hasn’t been implemented yet. - Debug Block Attributes: Outputs the block’s attributes in a readable format for easier debugging. The `NotImplemented` component helps quickly identify unmapped or unsupported block types, enhancing flexibility and streamlining your Nuxt/Vue integration. ## Usage with Mapping Example: ```vue ``` Simple Example in a Component: ```vue [headline.vue] ``` # @query-api/nuxt ## Features - **Query builder:** Easily build and execute queries directly from Nuxt, enabling flexible, real-time data retrieval from Craft CMS - **Built in Helper Components:** Connect your data directly with your Vue components, to speed up development. - **Get Only the Data You Need:** Avoid overfetching by using a custom function in the query builder to select only the fields you require. - **Pretty Json:** Json Transformer are in place to prettify the response. - **Support for Main Element Types:** Query addresses, assets, entries and users. - **Full Typescript Suppport**: Craft Query Builder with typescript support pretty cool hah?😎 - **Multisite Composables**: Built in composables to support Craft Multisites. - **SeoMatic Composables**: Connect SeoMatic with Nuxt fast with the built in SeoMatic composables. ## Examples Want to see how it works? ```ts [app.vue] const { data, error } = await useCraftEntry() .section('news') .fields(['title']) // add more field handles if you like .limit(3) .all() if (error.value) { console.error(error.value) } ``` It is as simple as that. 🚀 The response will be three entries of the section news. ## Further Resources - [Craft Query API](https://samuelreichor.at/libraries/craft-query-api): A Craft CMS Plugin, that powers this stuff. - [Vue SDK](https://samuelreichor.at/libraries/vue-craftcms): A package to use the query builder in Vue. - [JS SDK](https://samuelreichor.at/libraries/js-craftcms-api): Foundation to build a query builder with your preferred JS framework. # Introduction The `@query-api/nuxt` introduces a powerful query builder to your Nuxt app, allowing you to easily fetch data from Craft CMS, similar to how you would query in Twig. It allows you to connect your Craft CMS sections and entry types to Vue components, making it easy to render dynamic content in your Nuxt applications. ## Requirements - The [Craft Query API](https://samuelreichor.at/libraries/craft-query-api) plugin must be installed and properly configured in Craft CMS. - Node.js - 20.x or newer (but I recommend the [active LTS release](https://github.com/nodejs/release#release-schedule){rel=""nofollow""}) - Nuxt > 3 is required (Nuxt 4 is already supported). ## Supported Element Types - Addresses - Assets - Entries - Users ## Need Help? If you encounter bugs or have feature requests, please [submit an issue](https://github.com/samuelreichor/query-api/issues/new){rel=""nofollow""}. Your feedback helps improve the module! # Quick Start ## Requirements - The [Craft Query API](https://samuelreichor.at/libraries/craft-query-api) plugin must be installed and properly configured in Craft CMS. - Node.js - 20.x or newer (but I recommend the [active LTS release](https://github.com/nodejs/release#release-schedule){rel=""nofollow""}) - Nuxt > 3 is required (Nuxt 4 is already supported). ## Installation The fastest way to get started is with the `create-query-api` command-line tool. It scaffolds a complete project for you, including a pre-configured Craft CMS and a Nuxt frontend. Open your terminal and run the following command: ```bash npx create-query-api@latest query-api-nuxt --template nuxt ``` :content-snippet{slug="login-credentials"} ## Manual Installation If you want to integrate the `@query-api/nuxt` SDK into an existing Nuxt project or want to understand the setup process step by step, you can head over to the [Manual Setup Guide](https://samuelreichor.at/libraries/nuxt-craftcms/get-started/manual-setup) # Manual Setup This guide is for developers who want to integrate the Query API into an existing Nuxt project or for those who want to understand the setup process step-by-step. ::alert{variant="note"} If you prefer to dive straight into code, you can check out the [Nuxt demo project on GitHub](https://github.com/samuelreichor/query-api-craft-starter/tree/examples/nuxt){rel=""nofollow""}. :: ## Prerequisites Before you begin, please ensure you have the following set up: :content-snippet{slug="get-started-craft"} ### 2. Nuxt App You'll need a Nuxt application. If you're starting from scratch, you can create one inside your Craft project's root folder. ```bash npm create nuxt frontend ``` You can now open the `frontend` directory in your code editor to begin the setup. ## Installation and Folder Structure First, install the `@query-api/nuxt` SDK in your Nuxt project. ```bash npm install @query-api/nuxt ``` Next, we will create the following folder and file structure inside the `src` directory. This structure helps organize your code by separating concerns. ```bash ├── app │ ├── components │ │ ├── content │ │ │ ├── BlockHeadline.vue │ │ │ ├── ViewHome.vue │ ├── pages │ │ └── [...slug].vue │ ├── types │ │ └── base.ts │ └── app.vue ├── .env ├── nuxt.config.ts ``` ## Environment Variables Create a `.env` file in the root of your Nuxt project to store your Craft CMS connection details. ```bash [.env] # Allows Node.js to connect to local development URLs (e.g., DDEV). # Remove this in production. NODE_TLS_REJECT_UNAUTHORIZED=0 # The base URL of your Craft CMS backend. NUXT_CRAFT_BASE_URL=https://query-api-starter.ddev.site # The bearer token you generated in the Query API plugin settings. NUXT_CRAFT_AUTH_TOKEN="Bearer sqKTlMFsky_OeJVeDfnps75b2Gny4NBG" # Default of create-query-api starter template ``` ::alert{variant="note"} You can find/create the bearer token under `/admin/query-api/tokens` in the control panel. :: ## Generate Types :content-snippet{slug="generate-types"} ## Query API Configuration We can configure the Query API in the `nuxt.config.ts` file. ```ts [nuxt.config.ts] // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: '2025-05-15', devtools: { enabled: true }, modules: ['@query-api/nuxt'], craftcms: { baseUrl: process.env.NUXT_CRAFT_BASE_URL ?? '', authToken: process.env.NUXT_CRAFT_AUTH_TOKEN ?? '', debug: false, siteMap: [ { handle: 'en', path: '/', origin: 'http://localhost:3000', id: 1, }, { handle: 'de', path: '/de', origin: 'http://localhost:3000/de', id: 2, }, { handle: 'es', path: '/es', origin: 'http://localhost:3000/es', id: 3, }, ], }, }) ``` ## Content Driven Components These are the Vue components that will render your Craft CMS content. We recommend placing them in a dedicated `components/content` directory to distinguish them from general-purpose UI components. Here is an example of a component for a `headline` entry type that is used in a matrix block. ```vue [components/content/BlockHeadline.vue] ``` Next, create the main view component for your `home` section. ```vue [components/content/ViewHome.vue] ``` ## Root Entry Point Next let's create some NuxtLinks in the `app.vue` file. This will help to test, if everything works on both client and server side navigation. ```vue [app.vue] ``` ## Catch-All Route This dynamic route is the core of the page rendering logic. It captures every incoming URL, fetches the corresponding entry from Craft CMS, and renders it using the `CraftPage` component. ```vue [pages/[...slug\\].vue] ``` With this setup, navigating to any page on your Nuxt site will trigger a fetch to your Craft CMS backend, and the correct content will be rendered automatically. It's as simple as that! 🚀 --- ## Anything missing? If you have questions, run into issues, or have ideas for improvements, your feedback is very welcome! Please don't hesitate to [open an issue on GitHub](https://github.com/samuelreichor/query-api/issues/new){rel=""nofollow""}. Whether it's a bug report, a feature request, or a general suggestion, your input helps make this project better for everyone. # Configuration The `@query-api/nuxt` package can be configured in the `nuxt.config.ts` file. ## Example Configuration This example shows a minimal setup for a Nuxt application using the Query API: ```ts [nuxt.config.ts] // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ modules: ['@query-api/nuxt'], craftcms: { baseUrl: 'https://your-craft-backend.ddev.site', authToken: 'Bearer yourBearerToken', }, }) ``` ## Configuration Options ### `baseUrl` The base URL of your Craft CMS backend where the Query API is running. - **Type:** `string` - **Required:** `true` - **Example:** `https://your-craft-backend.ddev.site` ### `authToken` The authentication token for accessing the Craft CMS API. You can generate this Bearer token in the Query API plugin settings in your Craft control panel. - **Type:** `string` - **Required:** `true` - **Example:** `Bearer yourSecretToken...` ### `siteMap` The `siteMap` option allows you to define an array of your Craft Sites. This is essential for multi-site setups, as it enables the library to correctly resolve sites based on the request URL. - **Type:** `CraftSite[]` - **Default:** `[]` - **Example:** ```ts // Define the structure for a Craft site object. type CraftSite = { handle: string origin: string path: string id?: number label?: string lang?: string primary?: boolean } // Example siteMap configuration. siteMap: [ { handle: 'en', path: '/', origin: 'http://localhost:3000', id: 1, primary: true, }, { handle: 'de', path: '/de', origin: 'http://localhost:3000', // Origin can be the same for path-based multi-site id: 2, }, ] ``` ### `debug` Enable debug mode to log additional information to the console. This is useful during development for troubleshooting data fetching and component mapping. - **Type:** `boolean` - **Default:** `false` ### `enableEntryTypeMapping` By default, the library can map pages using a `sectionHandle:entryTypeHandle` format (e.g., `news:article`). If you prefer to only map by section handle, you can set this to `false`. - **Type:** `boolean` - **Default:** `true` ### `siteDetectionMode` This option controls how the current site is identified in a multi-site Craft CMS setup. - `path`: (Default) Detects the site from the URL path (e.g., `/de/news`). - `origin`: Detects the site from the domain or origin (e.g., `german-site.com`). - **Type:** `'path' | 'origin'` - **Default:** `'path'` ### `caching` Enables **client** side caching for all composables that fetch data on the cient side. Unfortunately this can't use the SSR payload that is generated by Nitro with SWR or ISR. This may change soon with this (PR) [{rel=""nofollow""}]. - **Type:** `boolean | { ttl: number }` - **Default:** `false` - **Example:** ```ts caching: { ttl: 3600 // cache for 1h } caching: true // cache for this session caching: false // disable caching ``` ## Default Configuration This is the default configuration for the @query-api/next package. ```ts [main.tsx] export const defaultCraftOptions = { baseUrl: '', authToken: '', debug: false, enableEntryTypeMapping: true, siteDetectionMode: siteDetectionModes.PATH, siteMap: [], caching: false, } ``` # Basic Usage Let's dive in and use that thing! This module offers two flexible methods for integrating Craft CMS data into your Nuxt application. ## Content Driven Components You can map Craft CMS section handles and matrix block handles to Vue components, enabling the module to automatically render pages and blocks based on your content structure. This method simplifies data rendering by letting the module handle the content logic. ::alert{variant="tip"} You can find detailed instructions on how to [connect your components](https://samuelreichor.at/libraries/nuxt-craftcms/usage/connect-components) here. :: ## Manually Queries You can also use the built-in query builder to dynamically fetch Craft CMS data. This approach allows you to construct precise queries and retrieve content as you need it. ::alert{variant="tip"} You can find detailed instructions on how [manual queries](https://samuelreichor.at/libraries/nuxt-craftcms/usage/connect-components) work here. :: # Content Driven Components This guide explains the steps to connect your Vue components with data from Craft CMS. We’ll set up a catch-all route, create a mapping object for components, query data, and use the `` and `` components to display content dynamically. ## Catch all route To support dynamic URLs in your Nuxt app, add a catch-all route by creating a file named `~/pages/[...slug].vue`. This route will capture all URLs and allow you to dynamically render the corresponding content. ## Mapping Object Define a mapping object in the `~/pages/[...slug].vue` file. This object connects each Craft CMS section handle to a Vue page component and each field handle to a specific Vue component. This setup allows the correct component to render based on the Craft CMS data. For example: ```vue ``` ### Working with Entry Types When `enableEntryTypeMapping` is set to `true` in your [nuxt.config.ts], you can link your Craft entries using the format `sectionHandle:entryTypeHandle`. If the entry type handle is `default` or matches the section handle, you don’t need to explicitly define the `:entryTypeHandle`. ```js const mapping: ContentMapping = { pages: { home: Home, // equivalent to home:default or home:home 'news:default': News, // equivalent to news or news:news 'news:reference': News, // section handle = news, entry type handle = reference }, components: { // additional components }, }; ``` ## Query Data Use the `useCraftQuery()` composable to fetch data from Craft CMS. Combine this with Nuxt’s `useRoute()` composable to get the correct URI based on the route parameters. Here’s what you can add the code to `[...slug].vue`: ```ts const uri = useCraftUri(); const { data, error } = await useCraftQuery('entries').uri(uri.value).one() if (error.value) { console.error(error.value) } console.log(data.value) ``` ::alert{variant="tip"} To easily enable multisite support, refer to the [Multisite Example](https://samuelreichor.at/libraries/nuxt-craftcms/usage/examples#multisite-example). :: ## Display Page To display the page data, use the `` component in `[...slug].vue`. This component automatically renders defined pages based on the mapping configuration and data received from Craft CMS. ::alert{variant="tip"} To find out more about the `` check out the [docs](https://samuelreichor.at/libraries/nuxt-craftcms/components/craft-page). :: Here’s what a full example file might look like: ```vue ``` This setup should render the correct Nuxt page based on the Craft CMS section handle. To verify the data structure Craft CMS sends to your page, you can add the following code to inspect the data in `home.vue`: ```vue [templates/pages/home.vue] ``` ## Display Components To connect Matrix blocks with Vue components, use the `` component. This component will dynamically render Vue components based on the content provided from Craft CMS. Example: ```vue [templates/pages/home.vue] ``` ::alert{variant="tip"} To find out more about the `` check out the [docs](https://samuelreichor.at/libraries/nuxt-craftcms/components/craft-area). :: # Manual Queries In this guide, we’ll cover how to use manual queries with the `useCraftQuery()` composable in Nuxt. This approach gives you direct control over the data you fetch, allowing you to display specific fields and customize your queries. ## Write a query Using `useCraftQuery()`, we can use the Craft CMS query builder for specific data. Here’s how to set up a query to fetch a list of related news articles: This query underneath will return three entries from the news section, containing only the title field. The await keyword is used to wait for the query to complete, and any errors are logged. ```ts const { data, error } = await useCraftQuery('entries') .section('news') .fields(['title']) .limit(3) .all() if (error.value) { console.error(error.value) } console.log(data.value) ``` ::alert{variant="tip"} Find out more about the available query methodes in the [useCraftQuery()](https://samuelreichor.at/libraries/nuxt-craftcms/composables/use-craft-query) docs. :: # Examples ## Full Example For a complete setup, check out the [full example here](https://github.com/samuelreichor/craft-nuxt-starter){rel=""nofollow""}. This monorepo includes a Craft and Nuxt setup where you can test around locally. Clone Repo: ```bash git clone git@github.com:samuelreichor/craft-nuxt-starter.git ``` ## Starter template The fastest way to get started is with the `create-query-api` command-line tool. It scaffolds a complete project for you, including a pre-configured Craft CMS and a Nuxt frontend. Open your terminal and run the following command: ```bash npx create-query-api@latest query-api-nuxt --template nuxt ``` ::alert{variant="note"} This command uses this [template on GitHub](https://github.com/samuelreichor/query-api-craft-starter/tree/examples/nuxt){rel=""nofollow""} :: ## Multisite Example ### Adding Multisites First, ensure that you have defined your multisite configuration in `nuxt.config.ts`: ```ts [nuxt.config.ts] export default defineNuxtConfig({ craftcms: { baseUrl: 'https://example.ddev.site', authToken: 'owiwrtgnfsjhsadgsdagf', siteMap: [ { handle: 'en', origin: 'http://localhost:3000', id: 1, }, { handle: 'de', origin: 'http://localhost:3000/de', id: 2, }, ], } }); ``` Once configured, you can use the `useCraftFullUrl` and `useCraftCurrentSite` composables to query your data. ### Querying Data In your catch-all route (`[...slug].vue`), you can retrieve the relevant data using the provided composables. Here’s an example: ```ts const uri = useCraftUri(); const currentSite = useCraftCurrentSite(); const { data, error } = await useCraftQuery('entries') .uri(uri.value) .site(currentSite.value.handle) .one(); if (error.value) { console.error(error.value); } ``` This approach ensures that your queries are multisite-aware, dynamically resolving the correct URI and site handle based on the current request. 🚀 # useCraftQuery This composable provides a simple way to fetch data from your Craft CMS Backend. It leverages the [JS SDK](https://samuelreichor.at/libraries/js-craftcms-api) to build query URLs and [useAsyncData](https://nuxt.com/docs/api/composables/use-async-data){rel=""nofollow""} for fetching data asynchronously in an ssr friedndly way. ## Element Types ```vue ``` ::alert{variant="note"} `data` and `error` are refs and they should be accessed with .value when used within the `