--- url: /foehn-framework/api/as-acf-block.md --- # #\[AsAcfBlock] Register a class as an ACF (Advanced Custom Fields) block. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsAcfBlock { public function __construct( public string $name, public string $title, public string $category = 'widgets', public ?string $icon = null, public ?string $description = null, public array $keywords = [], public string $mode = 'preview', public array $supports = [], public ?string $template = null, public array $postTypes = [], public ?string $parent = null, ) {} public function getFullName(): string {} } ``` ## Parameters | Parameter | Type | Default | Description | | ------------- | ---------- | ----------- | --------------------------------------- | | `name` | `string` | — | Block name without `acf/` (required) | | `title` | `string` | — | Display title (required) | | `category` | `string` | `'widgets'` | Block category | | `icon` | `?string` | `null` | Dashicon name or SVG | | `description` | `?string` | `null` | Block description | | `keywords` | `string[]` | `[]` | Search keywords | | `mode` | `string` | `'preview'` | Display mode: `preview`, `edit`, `auto` | | `supports` | `array` | `[]` | Block supports configuration | | `template` | `?string` | `null` | Custom template path | | `postTypes` | `string[]` | `[]` | Allowed post types (empty = all) | | `parent` | `?string` | `null` | Parent block name | ## Usage ### Basic ACF Block ```php addText('title') ->addWysiwyg('content') ->addImage('background'); } public function compose(array $block, array $fields): array { return [ 'title' => $fields['title'] ?? '', 'content' => $fields['content'] ?? '', 'background' => $fields['background'] ?? null, ]; } public function render(array $context, bool $isPreview = false): string { return $this->view->render('blocks/hero', $context); } } ``` ### Full Configuration ```php #[AsAcfBlock( name: 'testimonial', title: 'Testimonial', category: 'text', icon: 'format-quote', description: 'Display a customer testimonial', keywords: ['quote', 'review'], mode: 'preview', supports: [ 'align' => true, 'mode' => true, 'jsx' => true, ], postTypes: ['page', 'post'], )] ``` ### With Complex Fields ```php public static function fields(): FieldsBuilder { return (new FieldsBuilder('features')) ->addText('title') ->addRepeater('items', ['layout' => 'block']) ->addImage('icon') ->addText('title') ->addTextarea('description') ->endRepeater() ->addSelect('columns', [ 'choices' => ['2' => '2 Columns', '3' => '3 Columns'], ]); } ``` ## Required Interface Classes must implement `AcfBlockInterface`: ```php interface AcfBlockInterface { public static function fields(): FieldsBuilder; public function compose(array $block, array $fields): array; public function render(array $context, bool $isPreview = false): string; } ``` ## Related * [Guide: ACF Blocks](/guide/acf-blocks) * [`AcfBlockInterface`](./acf-block-interface) * [`#[AsBlock]`](./as-block) --- --- url: /foehn-framework/api/as-acf-field-group.md --- # #\[AsAcfFieldGroup] Register a class as an ACF (Advanced Custom Fields) field group for post types, page templates, taxonomies, or options pages. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsAcfFieldGroup { public function __construct( public string $name, public string $title, public array $location, public string $position = 'normal', public int $menuOrder = 0, public string $style = 'default', public string $labelPlacement = 'top', public string $instructionPlacement = 'label', public array $hideOnScreen = [], ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | ----------------------- | ---------- | ----------- | ----------------------------------------------------- | | `name` | `string` | — | Unique field group name (required) | | `title` | `string` | — | Display title in admin (required) | | `location` | `array` | — | Location rules (required) | | `position` | `string` | `'normal'` | Position: `acf_after_title`, `normal`, `side` | | `menuOrder` | `int` | `0` | Order in admin | | `style` | `string` | `'default'` | Style: `default`, `seamless` | | `labelPlacement` | `string` | `'top'` | Label placement: `top`, `left` | | `instructionPlacement` | `string` | `'label'` | Instruction placement: `label`, `field` | | `hideOnScreen` | `string[]` | `[]` | Elements to hide: `the_content`, `excerpt`, etc. | ## Location Syntax The `location` parameter supports two formats: ### Simplified Format For common use cases with a single condition: ```php // Post type #[AsAcfFieldGroup(location: ['post_type' => 'product'])] // Page template #[AsAcfFieldGroup(location: ['page_template' => 'page-faq.php'])] // Taxonomy #[AsAcfFieldGroup(location: ['taxonomy' => 'product_category'])] // Options page #[AsAcfFieldGroup(location: ['options_page' => 'theme-settings'])] // Multiple AND conditions #[AsAcfFieldGroup(location: [ 'post_type' => 'page', 'page_template' => 'page-contact.php', ])] ``` ### Full ACF Format For complex rules with OR/AND conditions: ```php #[AsAcfFieldGroup(location: [ // First OR group (post_type = product AND status != draft) [ ['param' => 'post_type', 'operator' => '==', 'value' => 'product'], ['param' => 'post_status', 'operator' => '!=', 'value' => 'draft'], ], // Second OR group (page template) [ ['param' => 'page_template', 'operator' => '==', 'value' => 'page-shop.php'], ], ])] ``` ## Usage ### Basic Field Group ```php 'product'], )] final class ProductFields implements AcfFieldGroupInterface { public static function fields(): FieldsBuilder { return (new FieldsBuilder('product_fields')) ->addText('sku', ['label' => 'SKU']) ->addNumber('price', ['label' => 'Price']) ->addWysiwyg('description', ['label' => 'Description']); } } ``` ### Full Configuration ```php #[AsAcfFieldGroup( name: 'property_fields', title: 'Property Details', location: ['post_type' => 'property'], position: 'acf_after_title', menuOrder: 0, style: 'seamless', labelPlacement: 'left', instructionPlacement: 'field', hideOnScreen: ['the_content', 'excerpt', 'featured_image'], )] final class PropertyFields implements AcfFieldGroupInterface { public static function fields(): FieldsBuilder { return (new FieldsBuilder('property_fields')) ->addText('external_id', ['label' => 'External ID']) ->addTab('General') ->addText('address', ['label' => 'Address']) ->addNumber('bedrooms', ['label' => 'Bedrooms']) ->addNumber('bathrooms', ['label' => 'Bathrooms']) ->addTab('Pricing') ->addNumber('price', ['label' => 'Price']) ->addSelect('status', [ 'label' => 'Status', 'choices' => [ 'available' => 'Available', 'pending' => 'Pending', 'sold' => 'Sold', ], ]); } } ``` ### Page Template Fields ```php #[AsAcfFieldGroup( name: 'faq_page_fields', title: 'FAQ Page', location: ['page_template' => 'page-faq.php'], )] final class FAQPageFields implements AcfFieldGroupInterface { public static function fields(): FieldsBuilder { return (new FieldsBuilder('faq_page_fields')) ->addText('intro_title') ->addTextarea('intro_text') ->addRepeater('faqs', ['layout' => 'block']) ->addText('question') ->addWysiwyg('answer') ->endRepeater(); } } ``` ### Taxonomy Term Fields ```php #[AsAcfFieldGroup( name: 'category_fields', title: 'Category Settings', location: ['taxonomy' => 'category'], )] final class CategoryFields implements AcfFieldGroupInterface { public static function fields(): FieldsBuilder { return (new FieldsBuilder('category_fields')) ->addImage('featured_image', ['label' => 'Featured Image']) ->addColorPicker('accent_color', ['label' => 'Accent Color']); } } ``` ### Complex Location Rules ```php #[AsAcfFieldGroup( name: 'shop_fields', title: 'Shop Fields', location: [ // Show on products that are not drafts [ ['param' => 'post_type', 'operator' => '==', 'value' => 'product'], ['param' => 'post_status', 'operator' => '!=', 'value' => 'draft'], ], // OR show on the shop page template [ ['param' => 'page_template', 'operator' => '==', 'value' => 'page-shop.php'], ], ], )] final class ShopFields implements AcfFieldGroupInterface { // ... } ``` ## Required Interface Classes must implement `AcfFieldGroupInterface`: ```php interface AcfFieldGroupInterface { public static function fields(): FieldsBuilder; } ``` ## Suggested File Structure ``` app/ ├── Fields/ │ ├── PostType/ │ │ ├── ProductFields.php │ │ └── PropertyFields.php │ ├── Page/ │ │ ├── FrontPageFields.php │ │ └── FAQPageFields.php │ ├── Taxonomy/ │ │ └── CategoryFields.php │ └── Options/ │ └── ThemeSettingsFields.php ``` ## Related * [`AcfFieldGroupInterface`](./acf-field-group-interface) * [`#[AsAcfBlock]`](./as-acf-block) * [Guide: ACF Blocks](/guide/acf-blocks) --- --- url: /foehn-framework/api/as-acf-options-page.md --- # #\[AsAcfOptionsPage] Register a class as an ACF (Advanced Custom Fields) options page. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsAcfOptionsPage { public function __construct( public string $pageTitle, public ?string $menuTitle = null, public ?string $menuSlug = null, public string $capability = 'edit_posts', public ?int $position = null, public ?string $parentSlug = null, public ?string $iconUrl = null, public bool $redirect = true, public ?string $postId = null, public bool $autoload = true, public ?string $updateButton = null, public ?string $updatedMessage = null, ) {} public function getMenuSlug(): string {} public function getMenuTitle(): string {} public function getPostId(): string {} public function isSubPage(): bool {} } ``` ## Parameters | Parameter | Type | Default | Description | | ---------------- | --------- | -------------- | ---------------------------------------------- | | `pageTitle` | `string` | — | Page title displayed on the page (required) | | `menuTitle` | `?string` | `$pageTitle` | Title displayed in admin menu | | `menuSlug` | `?string` | sanitized title| URL slug for the page | | `capability` | `string` | `'edit_posts'` | Required capability to view page | | `position` | `?int` | `null` | Menu position (null = bottom) | | `parentSlug` | `?string` | `null` | Parent page slug (for sub-pages) | | `iconUrl` | `?string` | `null` | Menu icon (dashicon, URL, or base64 SVG) | | `redirect` | `bool` | `true` | Redirect to first child page | | `postId` | `?string` | `$menuSlug` | Custom post\_id for `get_field()` | | `autoload` | `bool` | `true` | Autoload options for better performance | | `updateButton` | `?string` | `null` | Custom text for save button | | `updatedMessage` | `?string` | `null` | Custom message after saving | ## Usage ### Basic Options Page ```php addText('site_name') ->addTextarea('footer_text'); } } ``` ### Full Configuration ```php #[AsAcfOptionsPage( pageTitle: 'Theme Settings', menuTitle: 'Theme', menuSlug: 'theme-settings', capability: 'manage_options', position: 59, iconUrl: 'dashicons-admin-generic', redirect: false, postId: 'theme_options', autoload: true, updateButton: 'Save Theme Settings', updatedMessage: 'Theme settings have been updated.', )] ``` ### Sub-Page ```php #[AsAcfOptionsPage( pageTitle: 'Social Media Settings', parentSlug: 'theme-settings', capability: 'manage_options', )] final class SocialSettings implements AcfOptionsPageInterface { public static function fields(): FieldsBuilder { return (new FieldsBuilder('social_settings')) ->addUrl('facebook') ->addUrl('twitter') ->addUrl('instagram'); } } ``` ### Without Fields (External Definition) ```php #[AsAcfOptionsPage( pageTitle: 'External Settings', menuSlug: 'external-settings', )] final class ExternalSettings { // Fields defined via ACF UI or JSON import } ``` ## Helper Methods ### getMenuSlug() Returns the effective menu slug (explicit or sanitized from page title). ```php $attr = new AsAcfOptionsPage(pageTitle: 'Theme Settings'); $attr->getMenuSlug(); // 'theme-settings' $attr = new AsAcfOptionsPage(pageTitle: 'Theme Settings', menuSlug: 'custom'); $attr->getMenuSlug(); // 'custom' ``` ### getMenuTitle() Returns the effective menu title (explicit or page title). ```php $attr = new AsAcfOptionsPage(pageTitle: 'Theme Settings'); $attr->getMenuTitle(); // 'Theme Settings' $attr = new AsAcfOptionsPage(pageTitle: 'Theme Settings', menuTitle: 'Theme'); $attr->getMenuTitle(); // 'Theme' ``` ### getPostId() Returns the effective post\_id for `get_field()` calls. ```php $attr = new AsAcfOptionsPage(pageTitle: 'Theme', menuSlug: 'theme-settings'); $attr->getPostId(); // 'theme-settings' $attr = new AsAcfOptionsPage(pageTitle: 'Theme', postId: 'custom_id'); $attr->getPostId(); // 'custom_id' ``` ### isSubPage() Returns `true` if this is a sub-page (has a parent). ```php $attr = new AsAcfOptionsPage(pageTitle: 'Theme'); $attr->isSubPage(); // false $attr = new AsAcfOptionsPage(pageTitle: 'Social', parentSlug: 'theme'); $attr->isSubPage(); // true ``` ## Optional Interface Implementing `AcfOptionsPageInterface` is optional. When implemented, Føhn automatically registers the field group: ```php interface AcfOptionsPageInterface { public static function fields(): FieldsBuilder; } ``` ## Related * [Guide: ACF Options Pages](/guide/acf-options-pages) * [`AcfOptionsPageInterface`](./acf-options-page-interface) * [`#[AsAcfBlock]`](./as-acf-block) --- --- url: /foehn-framework/api/as-action.md --- # #\[AsAction] Register a method as a WordPress action hook handler. ## Signature ```php #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final readonly class AsAction { public function __construct( public string $hook, public int $priority = 10, public int $acceptedArgs = 1, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | -------------- | -------- | ------- | ---------------------------------------- | | `hook` | `string` | — | The WordPress action hook name | | `priority` | `int` | `10` | Priority for the hook | | `acceptedArgs` | `int` | `1` | Number of arguments the callback accepts | ## Usage ### Basic Usage ```php post_type !== 'product') { return; } // Handle product save } ``` ### Multiple Actions The attribute is repeatable: ```php #[AsAction('admin_init')] #[AsAction('init')] public function initialize(): void { // Runs on both hooks } ``` ## Related * [Guide: Hooks](/guide/hooks) * [`#[AsFilter]`](./as-filter) --- --- url: /foehn-framework/api/as-block.md --- # #\[AsBlock] Register a class as a native Gutenberg block. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsBlock { public function __construct( public string $name, public string $title, public string $category = 'widgets', public ?string $icon = null, public ?string $description = null, public array $keywords = [], public array $supports = [], public ?string $parent = null, public array $ancestor = [], public bool $interactivity = false, public ?string $interactivityNamespace = null, public ?string $template = null, public array $allowedBlocks = [], public array $innerBlocksTemplate = [], public string|bool|null $innerBlocksTemplateLock = null, ) {} public function getInteractivityNamespace(): string {} public static function hasInnerBlocks( array $allowedBlocks, array $innerBlocksTemplate, string|bool|null $innerBlocksTemplateLock, ): bool {} } ``` ## Parameters | Parameter | Type | Default | Description | | ------------------------- | -------------------- | ------------- | ----------------------------------------------------------------- | | `name` | `string` | — | Block name with namespace (required) | | `title` | `string` | — | Display title (required) | | `category` | `string` | `'widgets'` | Block category | | `icon` | `?string` | `null` | Dashicon name or SVG | | `description` | `?string` | `null` | Block description | | `keywords` | `string[]` | `[]` | Search keywords | | `supports` | `array` | `[]` | Block supports configuration | | `parent` | `?string` | `null` | Parent block name | | `ancestor` | `string[]` | `[]` | Ancestor block names | | `interactivity` | `bool` | `false` | Enable WordPress Interactivity API | | `interactivityNamespace` | `?string` | Block name | Custom interactivity namespace | | `template` | `?string` | Auto-resolved | Template path | | `allowedBlocks` | `string[]` | `[]` | Block names allowed as inner blocks | | `innerBlocksTemplate` | `array` | `[]` | InnerBlocks template | | `innerBlocksTemplateLock` | `string\|bool\|null` | `null` | InnerBlocks lock: `'all'`, `'insert'`, `'contentOnly'` or `false` | Setting any of the three `allowedBlocks` / `innerBlocksTemplate` / `innerBlocksTemplateLock` parameters makes the block a container: the editor renders `InnerBlocks` instead of a server-rendered preview, and the inner markup reaches the Twig template as `content`. ## Assets There is no parameter for a block's stylesheet or script. Both are found by naming them after the block, and are loaded when the files exist: | File | Loaded | WordPress argument | | ------------------------------- | --------------------------------- | ------------------------ | | `assets/css/blocks/callout.css` | front end and editor | `style_handles` | | `assets/js/blocks/callout.js` | front end, when the block is used | `view_script_module_ids` | For a block named `theme/callout`, the file name is the part after the namespace — `callout`. Both paths are theme-relative and resolved with `get_theme_file_path()`, so a child theme can override either file. Because the assets are attached to the block type rather than enqueued globally, WordPress loads them only on pages that actually render the block, and loads the stylesheet into the editor as well — which is what makes the server-rendered preview look like the front end. The script is registered as a [script module](https://developer.wordpress.org/reference/functions/wp_register_script_module/), so it is served with `type="module"`. It can use `import`, including bare specifiers such as `@wordpress/interactivity`, which WordPress resolves through the import map it prints for registered modules. Modules are deferred and run in strict mode, so load order needs no thought. A block with no such files needs no configuration, and a file that does not exist registers nothing: registering an absent asset would emit a 404 on every page using the block. ## Usage ### Basic Block ```php ['type' => 'string', 'default' => 'info'], 'message' => ['type' => 'string', 'default' => ''], ]; } public function compose(array $attributes, string $content, WP_Block $block): array { return [ 'type' => $attributes['type'], 'message' => $attributes['message'], ]; } public function render(array $attributes, string $content, WP_Block $block): string { return $this->view->render('blocks/alert', $this->compose($attributes, $content, $block)); } } ``` ### Interactive Block ```php ['type' => 'number', 'default' => 0], ]; } public static function initialState(): array { return ['totalClicks' => 0]; } public function initialContext(array $attributes): array { return ['count' => $attributes['initialCount']]; } public function compose(array $attributes, string $content, WP_Block $block): array { return ['context' => $this->initialContext($attributes)]; } public function render(array $attributes, string $content, WP_Block $block): string { // ... } } ``` ### With Supports ```php #[AsBlock( name: 'theme/card', title: 'Card', supports: [ 'align' => ['wide', 'full'], 'color' => ['background' => true, 'text' => true], 'spacing' => ['padding' => true], 'html' => false, ], )] ``` ## Required Interfaces * Basic blocks: `BlockInterface` * Interactive blocks: `InteractiveBlockInterface` ## Related * [Guide: Native Blocks](/guide/native-blocks) * [`BlockInterface`](./block-interface) * [`InteractiveBlockInterface`](./interactive-block-interface) --- --- url: /foehn-framework/api/as-block-binding.md --- # #\[AsBlockBinding] Registers a block bindings source: a value computed at render time and bound to a block attribute. ::: tip A value that is merely **stored** needs none of this. A key declared with [`#[AsPostMeta]`](./as-post-meta) is bindable through core's own `core/post-meta` with no source of your own. See the [guide](/guide/block-bindings). ::: ## Signature ```php ` | `[]` | Block context keys the value needs, e.g. `postId` | ## BlockBindingInterface ```php 'price']` | | `$block` | The block being rendered; its `context` holds the keys `usesContext` asked for | | `$attribute` | Which attribute is being bound — a source on two attributes is called twice | Returning `null` leaves the attribute as the block author wrote it. ## Usage ```php #[AsBlockBinding(name: 'theme/reading-time', label: 'Reading time', usesContext: ['postId'])] final readonly class ReadingTime implements BlockBindingInterface { public function value(array $args, WP_Block $block, string $attribute): ?string { // … } } ``` ## Notes * **Registered on `init`**, which is where `register_block_bindings_source()` belongs. * **The class is resolved when a bound block renders**, not when the source is registered, so a source nothing binds to costs nothing. * **Which attributes accept a binding is version-dependent.** See the [guide](/guide/block-bindings#which-attributes-accept-a-binding) for the WordPress 7.0 list and the filter that extends it. * **A WordPress older than 6.5 gets no sources** rather than a fatal error. ## Related * [Guide: Block Bindings](/guide/block-bindings) * [#\[AsPostMeta\]](./as-post-meta) * [#\[AsBlock\]](./as-block) --- --- url: /foehn-framework/api/as-block-category.md --- # #\[AsBlockCategory] Register a custom block category. WordPress core already registers `text`, `media`, `design`, `widgets`, `theme`, `embed` and `reusable`. Use this attribute only for a slug that core does not provide. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] final readonly class AsBlockCategory { public function __construct( public string $slug, public string $title, public ?string $icon = null, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | --------- | ------- | ------------------------ | | `slug` | `string` | — | Category slug (required) | | `title` | `string` | — | Display title (required) | | `icon` | `?string` | `null` | Dashicon name | ## Usage ### Single Category ```php

Welcome

``` ### With Dynamic Content Implement `BlockPatternInterface`: ```php \Timber\Timber::get_posts([ 'posts_per_page' => 3, ]), ]; } } ``` ### Full Configuration ```php #[AsBlockPattern( name: 'theme/pricing-table', title: 'Pricing Table', categories: ['featured', 'pricing'], keywords: ['price', 'plans'], blockTypes: ['core/group'], description: 'A pricing comparison table', template: 'patterns/pricing', viewportWidth: 1400, inserter: true, )] ``` ## Template Resolution Default: `theme/hero-section` → `patterns/hero-section.twig` Custom with `template` parameter. ## Related * [Guide: Block Patterns](/guide/block-patterns) * [`BlockPatternInterface`](./block-pattern-interface) --- --- url: /foehn-framework/api/as-cli-command.md --- # #\[AsCliCommand] Register a class as a WP-CLI command. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsCliCommand { public function __construct( public string $name, public string $description, public ?string $longDescription = null, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | ----------------- | --------- | ------- | ------------------------------- | | `name` | `string` | — | Command name (required) | | `description` | `string` | — | Short description (required) | | `longDescription` | `?string` | `null` | Detailed help (docblock format) | ## Namespace Commands are registered under `wp foehn `. ## Usage ### Basic Command ```php * : Path to CSV file * * [--dry-run] * : Preview without importing */ public function __invoke(array $args, array $assocArgs): void { $file = $args[0]; $dryRun = isset($assocArgs['dry-run']); // Import logic } } ``` **Usage:** `wp foehn import:products data.csv --dry-run` ### With Subcommands ```php #[AsCliCommand( name: 'cache', description: 'Manage cache', )] final class CacheCommand { public function clear(): void { wp_cache_flush(); WP_CLI::success('Cache cleared'); } public function warm(): void { // Warm cache WP_CLI::success('Cache warmed'); } } ``` **Usage:** * `wp foehn cache clear` * `wp foehn cache warm` ### With Long Description ```php #[AsCliCommand( name: 'sync', description: 'Sync data from API', longDescription: <<<'DOC' ## DESCRIPTION Synchronizes data from the external API. ## OPTIONS [--force] : Force full sync ## EXAMPLES wp foehn sync wp foehn sync --force DOC, )] ``` ## Related * [Guide: CLI Commands](/guide/cli-commands) --- --- url: /foehn-framework/api/as-context-provider.md --- # #\[AsContextProvider] Register a class as a context provider that adds data to specific templates. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsContextProvider { public function __construct( public string|array $templates, public int $priority = 10, ) {} public function getTemplates(): array {} } ``` ## Parameters | Parameter | Type | Default | Description | | ----------- | --------------- | ------- | --------------------------------------- | | `templates` | `string\|array` | — | Template pattern(s) to match (required) | | `priority` | `int` | `10` | Execution priority (lower = earlier) | ## Template Patterns * `'single'` — Exact match * `'single-*'` — Wildcard (matches `single-post`, `single-product`, etc.) * `'*'` — Global (all templates) * `['home', 'front-page']` — Multiple templates ## Usage ### Global Provider ```php with('current_year', date('Y')) ->with('is_home', is_front_page()); } } ``` ### Template-Specific ```php use App\Models\Product; #[AsContextProvider('single-product')] final class ProductContextProvider implements ContextProviderInterface { public function provide(TemplateContext $context): TemplateContext { $product = $context->post(Product::class); if (!$product) { return $context; } return $context->with('related', $product->relatedProducts(4)); } } ``` ### Wildcard Pattern ```php #[AsContextProvider('archive-*')] final class ArchiveContextProvider implements ContextProviderInterface { public function provide(TemplateContext $context): TemplateContext { if (!$context->posts) { return $context; } return $context->with('pagination', $context->posts->pagination()); } } ``` ### Multiple Templates ```php #[AsContextProvider(['home', 'front-page'])] final class HomeContextProvider implements ContextProviderInterface { public function provide(TemplateContext $context): TemplateContext { return $context->with('featured', $this->getFeaturedPosts()); } } ``` ### With Priority ```php // Runs first #[AsContextProvider('*', priority: 5)] final class BaseContextProvider implements ContextProviderInterface {} // Runs last #[AsContextProvider('*', priority: 20)] final class FinalContextProvider implements ContextProviderInterface {} ``` ## Required Interface Classes must implement `ContextProviderInterface`: ```php use Studiometa\Foehn\Views\TemplateContext; interface ContextProviderInterface { public function provide(TemplateContext $context): TemplateContext; } ``` ## Related * [Guide: Context Providers](/guide/context-providers) * [`ContextProviderInterface`](./context-provider-interface) * [`#[AsTemplateController]`](./as-template-controller) --- --- url: /foehn-framework/api/as-discovery.md --- # #\[AsDiscovery] Declares the WordPress lifecycle phase a discovery class applies in. A discovery is itself discovered: any class implementing `Tempest\Discovery\Discovery` inside a scanned location is found, resolved from the container and run — whether it ships with Føhn, comes from a Composer package or lives in the theme's `app/` directory. This attribute is how such a class chooses its timing. ## Signature ```php >` | `[]` | Location rules for where to show the field group | | `menuOrder` | `int` | `0` | Order in the admin meta box list | | `position` | `string` | `normal` | Position: `acf_after_title`, `normal`, `side` | | `style` | `string` | `default` | Style: `default`, `seamless` | | `labelPlacement` | `string` | `top` | Label placement: `top`, `left` | | `instructionPlacement` | `string` | `label` | Instruction placement: `label`, `field` | | `active` | `bool` | `true` | Whether the field group is active | ## Usage ### Basic Usage ```php addText('sku', ['label' => 'SKU']) ->addNumber('price', ['label' => 'Price']) ->addWysiwyg('specifications', ['label' => 'Specifications']); return $fields; } } ``` ### For Page Templates ```php #[AsFieldGroup( key: 'front_page_fields', title: 'Front Page Settings', location: [ ['page_template', '==', 'front-page.php'], ], position: 'acf_after_title', style: 'seamless', )] final class FrontPageFields { public static function fields(): FieldsBuilder { $fields = new FieldsBuilder('front_page_fields'); $fields ->addText('hero_title', ['label' => 'Hero Title']) ->addTextarea('hero_subtitle', ['label' => 'Hero Subtitle']) ->addImage('hero_image', ['label' => 'Hero Image']); return $fields; } } ``` ### For Taxonomies ```php #[AsFieldGroup( key: 'category_fields', title: 'Category Settings', location: [ ['taxonomy', '==', 'category'], ], )] final class CategoryFields { public static function fields(): FieldsBuilder { $fields = new FieldsBuilder('category_fields'); $fields ->addImage('featured_image', ['label' => 'Featured Image']) ->addColorPicker('accent_color', ['label' => 'Accent Color']); return $fields; } } ``` ### Complex Location Rules ```php #[AsFieldGroup( key: 'sidebar_fields', title: 'Sidebar Settings', location: [ ['post_type', '==', 'post'], ['post_type', '==', 'page'], ], position: 'side', )] final class SidebarFields { public static function fields(): FieldsBuilder { $fields = new FieldsBuilder('sidebar_fields'); $fields ->addTrueFalse('hide_sidebar', ['label' => 'Hide Sidebar']) ->addSelect('sidebar_position', [ 'label' => 'Position', 'choices' => ['left' => 'Left', 'right' => 'Right'], ]); return $fields; } } ``` ## CLI Scaffolding Generate a field group with the CLI: ```bash # For a post type wp foehn make:field-group ProductFields --post-type=product # For a page template wp foehn make:field-group FrontPageFields --page-template=front-page # For a taxonomy wp foehn make:field-group CategoryFields --taxonomy=category # Preview without creating wp foehn make:field-group ProductFields --post-type=product --dry-run ``` ## Related * [`#[AsOptionsPage]`](./as-options-page) * [`#[AsAcfBlock]`](./as-acf-block) * [ACF Builder Documentation](https://github.com/StoutLogic/acf-builder) --- --- url: /foehn-framework/api/as-filter.md --- # #\[AsFilter] Register a method as a WordPress filter hook handler. ## Signature ```php #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final readonly class AsFilter { public function __construct( public string $hook, public int $priority = 10, public int $acceptedArgs = 1, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | -------------- | -------- | ------- | ---------------------------------------- | | `hook` | `string` | — | The WordPress filter hook name | | `priority` | `int` | `10` | Priority for the hook | | `acceptedArgs` | `int` | `1` | Number of arguments the callback accepts | ## Usage ### Basic Usage ```php ' . $content . ''; } ``` ### With Multiple Arguments ```php #[AsFilter('wp_nav_menu_items', priority: 10, acceptedArgs: 2)] public function addSearchToMenu(string $items, object $args): string { if ($args->theme_location === 'primary') { $items .= '
  • ' . get_search_form(false) . '
  • '; } return $items; } ``` ### Multiple Filters The attribute is repeatable: ```php #[AsFilter('the_title')] #[AsFilter('single_post_title')] public function formatTitle(string $title): string { return ucwords($title); } ``` ## Related * [Guide: Hooks](/guide/hooks) * [`#[AsAction]`](./as-action) --- --- url: /foehn-framework/api/as-image-size.md --- # #\[AsImageSize] Register a custom WordPress image size. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsImageSize { public function __construct( public int $width, public int $height = 0, public bool $crop = false, public ?string $name = null, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | --------- | --------- | ------- | ----------------------------------------------------- | | `width` | `int` | — | Image width in pixels (required) | | `height` | `int` | `0` | Image height in pixels (0 for proportional scaling) | | `crop` | `bool` | `false` | Whether to crop the image to exact dimensions | | `name` | `?string` | `null` | Custom size name (derived from class name if omitted) | ## Name Derivation When `name` is not specified, it's automatically derived from the class name: 1. Common suffixes (`Image`, `Size`, `ImageSize`) are removed 2. PascalCase is converted to snake\_case | Class Name | Derived Name | | ------------------ | --------------- | | `HeroImage` | `hero` | | `ThumbnailLarge` | `thumbnail_large` | | `SocialShareImage` | `social_share` | | `CardSize` | `card` | ## Usage ### Basic Image Size ```php {# With Timber's resize #} {{ post.thumbnail.alt }} ``` ### In PHP ```php // Get image URL for custom size $url = wp_get_attachment_image_url($attachment_id, 'social_share'); // Get full image tag $img = wp_get_attachment_image($attachment_id, 'hero'); ``` ## Organization We recommend organizing image sizes in a dedicated directory: ``` app/ └── ImageSizes/ ├── HeroImage.php ├── ThumbnailLarge.php └── SocialShareImage.php ``` ## Related * [WordPress Image Sizes](https://developer.wordpress.org/reference/functions/add_image_size/) * [`#[AsPostType]`](./as-post-type) — Post types with thumbnail support --- --- url: /foehn-framework/api/as-menu.md --- # #\[AsMenu] Register a WordPress navigation menu location. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsMenu { public function __construct( public string $location, public string $description, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | ------------- | -------- | ------- | ---------------------------------------- | | `location` | `string` | — | Menu location slug (required) | | `description` | `string` | — | Human-readable label shown in admin (required) | ## Usage ### Basic Menu ```php ` when assigned in WordPress admin: ```twig {# In any Twig template #} {% if menus.primary %} {% endif %} ``` ## Related * [Guide: Menus](/guide/menus) --- --- url: /foehn-framework/api/as-options-page.md --- # #\[AsOptionsPage] Register a class as an ACF options page. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsOptionsPage { public function __construct( public string $pageTitle, public string $menuTitle, public string $menuSlug, public string $capability = 'edit_posts', public string $parentSlug = '', public int $position = 99, public string $iconUrl = 'dashicons-admin-generic', public bool $redirect = true, public string $postId = 'options', public bool $autoload = true, public string $updateButton = 'Update', public string $updatedMessage = 'Options Updated', ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | ---------------- | -------- | ------------------------- | ---------------------------------------------- | | `pageTitle` | `string` | — | Title displayed on the options page | | `menuTitle` | `string` | — | Title displayed in the admin menu | | `menuSlug` | `string` | — | Unique slug for the options page URL | | `capability` | `string` | `edit_posts` | Required capability to access the page | | `parentSlug` | `string` | `''` | Parent menu slug (empty for top-level menu) | | `position` | `int` | `99` | Menu position (only for top-level menus) | | `iconUrl` | `string` | `dashicons-admin-generic` | Dashicon class or icon URL | | `redirect` | `bool` | `true` | Whether to redirect to the first child page | | `postId` | `string` | `options` | Custom post ID for storing options | | `autoload` | `bool` | `true` | Whether to autoload options on every page load | | `updateButton` | `string` | `Update` | Text for the update button | | `updatedMessage` | `string` | `Options Updated` | Message displayed after saving | ## Usage ### Basic Usage ```php addTab('general', ['label' => 'General']) ->addText('company_name', ['label' => 'Company Name']) ->addEmail('contact_email', ['label' => 'Contact Email']) ->addTab('social', ['label' => 'Social Media']) ->addUrl('facebook_url', ['label' => 'Facebook']) ->addUrl('twitter_url', ['label' => 'Twitter']) ->addUrl('instagram_url', ['label' => 'Instagram']); return $fields; } /** * Get an option value. */ public static function get(string $key, mixed $default = null): mixed { $value = get_field($key, 'option'); return $value !== null && $value !== false ? $value : $default; } } ``` ### Submenu Page ```php #[AsOptionsPage( pageTitle: 'Footer Settings', menuTitle: 'Footer', menuSlug: 'footer-settings', parentSlug: 'theme-settings', )] final class FooterSettings { public static function fields(): FieldsBuilder { $fields = new FieldsBuilder('footer_settings'); $fields ->addWysiwyg('footer_text', ['label' => 'Footer Text']) ->addRepeater('footer_links', ['label' => 'Footer Links']) ->addText('label', ['label' => 'Label']) ->addUrl('url', ['label' => 'URL']) ->endRepeater(); return $fields; } } ``` ### With Custom Capability ```php #[AsOptionsPage( pageTitle: 'Advanced Settings', menuTitle: 'Advanced', menuSlug: 'advanced-settings', capability: 'manage_options', // Only administrators parentSlug: 'theme-settings', )] final class AdvancedSettings { public static function fields(): FieldsBuilder { $fields = new FieldsBuilder('advanced_settings'); $fields ->addTrueFalse('enable_debug', ['label' => 'Enable Debug Mode']) ->addTextarea('custom_scripts', ['label' => 'Custom Scripts']); return $fields; } } ``` ## Accessing Options ### In PHP ```php // Using the static helper $companyName = ThemeSettings::get('company_name'); $email = ThemeSettings::get('contact_email', 'default@example.com'); // Using ACF directly $companyName = get_field('company_name', 'option'); ``` ### In Twig Templates ```twig {# Using ACF function #} {{ fn('get_field', 'company_name', 'option') }} {# Or add to context via a context provider #} {{ options.company_name }} ``` ## CLI Scaffolding Generate an options page with the CLI: ```bash # Top-level options page wp foehn make:options-page ThemeSettings # Submenu options page wp foehn make:options-page FooterSettings --parent=theme-settings # With custom icon wp foehn make:options-page SocialSettings --icon=dashicons-share # Preview without creating wp foehn make:options-page ThemeSettings --dry-run ``` ## Related * [`#[AsFieldGroup]`](./as-field-group) * [ACF Options Page Documentation](https://www.advancedcustomfields.com/resources/options-page/) --- --- url: /foehn-framework/api/as-post-meta.md --- # #\[AsPostMeta] Registers a meta key with WordPress through `register_meta()`, which is what puts a custom field in the REST API — and therefore in the block editor and in block bindings. The attribute is repeatable and goes on the model that owns the field, because that model already declares its post type and already holds the accessors that read the key. ## Signature ```php meta('price'); return $price ? (float) $price : null; } public static function sanitizeSku(mixed $value): string { return strtoupper((string) $value); } } ``` ## Subtype inference `register_meta('post', 'price', […])` without an `object_subtype` registers the key for **every** post type. A model declaring a key on itself never means that, so the subtype is read off the class: | Declared on a class carrying | Subtype | | -------------------------------- | -------------------- | | `#[AsPostType(name: 'product')]` | `product` | | `#[AsTaxonomy(name: 'genre')]` | `genre` | | `#[AsTimberModel('post')]` | `post` | | none of the above | `''` — every subtype | `objectType: 'user'` and `objectType: 'comment'` have no subtypes in WordPress and are always registered globally. An explicit `objectSubtype` always wins. ## Sanitisers are method names, never closures A discovery item reaches the cache through `var_export()`, so an attribute cannot hold a closure — it would work in development and fail only once caching is on, which is to say only in production. `sanitize` therefore names a method, resolved when `apply()` runs. The method must be **public and static**: the declaring class is usually a Timber model, and `Timber\Post` declares a protected constructor, so there is nothing to call an instance method on. ## Arrays and objects need a schema WordPress cannot build a REST schema for an array whose items it cannot describe, and says so only under `WP_DEBUG`. Føhn refuses the declaration instead: ```php #[AsPostMeta(key: 'credits', type: 'array', schema: ['items' => ['type' => 'string']])] ``` Scalars need nothing: WordPress derives their schema from `type`, and wraps it in an array itself when `single` is `false`. ## It does not conflict with ACF ACF stores its values in ordinary post meta. Declaring `#[AsPostMeta(key: 'price')]` for a key ACF also manages is legitimate and useful: ACF keeps the editing UI, and the declaration gives the key a REST schema and makes it bindable. They are not alternatives at the storage layer. ## Related * [Guide: Post Types](/guide/post-types) * [#\[AsPostType\]](./as-post-type) * [#\[AsTaxonomy\]](./as-taxonomy) --- --- url: /foehn-framework/api/as-post-type.md --- # #\[AsPostType] Register a class as a custom WordPress post type. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsPostType { public function __construct( public string $name, public ?string $singular = null, public ?string $plural = null, public bool $public = true, public bool $hasArchive = false, public bool $showInRest = true, public ?string $menuIcon = null, public array $supports = ['title', 'editor', 'thumbnail'], public array $taxonomies = [], public ?string $rewriteSlug = null, public bool $hierarchical = false, public ?int $menuPosition = null, public array $labels = [], public array|false|null $rewrite = null, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | -------------- | ----------------------- | ---------------------------------- | -------------------------------------------------- | | `name` | `string` | — | Post type slug (required) | | `singular` | `?string` | `null` | Singular label | | `plural` | `?string` | `null` | Plural label | | `public` | `bool` | `true` | Whether publicly visible | | `hasArchive` | `bool` | `false` | Enable archive pages | | `showInRest` | `bool` | `true` | Enable REST API and Gutenberg | | `menuIcon` | `?string` | `null` | Dashicon name or custom icon URL | | `supports` | `string[]` | `['title', 'editor', 'thumbnail']` | Supported features | | `taxonomies` | `string[]` | `[]` | Associated taxonomy slugs | | `rewriteSlug` | `?string` | `null` | Custom URL slug (shorthand for `rewrite`) | | `hierarchical` | `bool` | `false` | Whether hierarchical (like pages) | | `menuPosition` | `?int` | `null` | Position in the admin menu | | `labels` | `array` | `[]` | Custom labels (merged with auto-generated ones) | | `rewrite` | `array\|false\|null` | `null` | Full rewrite config, `false` to disable, or `null` | ## Usage ### Basic Post Type ```php meta('price') ? (float) $this->meta('price') : null; } } ``` ### With Advanced Configuration Implement `ConfiguresPostType` for full control: ```php setCapabilityType('event') ->setMapMetaCap(true); } } ``` ## Supported Features Available values for `supports`: * `title` — Post title * `editor` — Content editor * `thumbnail` — Featured image * `excerpt` — Excerpt field * `author` — Author selection * `comments` — Comments * `trackbacks` — Trackbacks * `revisions` — Revisions * `custom-fields` — Custom fields * `page-attributes` — Page attributes (order, parent) * `post-formats` — Post formats ## Related * [Guide: Post Types](/guide/post-types) * [`#[AsTaxonomy]`](./as-taxonomy) --- --- url: /foehn-framework/api/as-rest-route.md --- # #\[AsRestRoute] Register a method as a WordPress REST API endpoint. ## Signature ```php #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final readonly class AsRestRoute { public function __construct( public string $namespace, public string $route, public string $method = 'GET', public ?string $permission = null, public array $args = [], ) {} public function getMethodConstant(): string {} } ``` ## Parameters | Parameter | Type | Default | Description | | ------------ | --------- | ------- | --------------------------------------------------------------------- | | `namespace` | `string` | — | REST namespace (e.g., `theme/v1`) | | `route` | `string` | — | Route pattern (required) | | `method` | `string` | `'GET'` | HTTP method | | `permission` | `?string` | `null` | Permission callback, `'public'`, or `null` for default capability | | `args` | `array` | `[]` | Request arguments schema | ## Default Permission When `permission` is `null` (the default), the route requires the `edit_posts` capability. This ensures routes are not accidentally exposed to subscribers or other low-privilege users. Configure the default via `FoehnConfig`: ```php Kernel::boot(__DIR__, [ 'rest_default_capability' => 'manage_options', // Require admin // or 'rest_default_capability' => null, // Only require authentication ]); ``` ## HTTP Methods * `GET` — Read operations * `POST` — Create operations * `PUT` — Full update operations * `PATCH` — Partial update operations * `DELETE` — Delete operations ## Usage ### Basic Endpoint ```php 'product']); return new WP_REST_Response($products); } } ``` **Endpoint:** `GET /wp-json/theme/v1/products` ### Route Parameters ```php #[AsRestRoute( namespace: 'theme/v1', route: '/products/(?P\d+)', method: 'GET', )] public function show(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $product = get_post($id); if (!$product) { return new WP_REST_Response(['error' => 'Not found'], 404); } return new WP_REST_Response($product); } ``` ### Public Endpoint ```php #[AsRestRoute( namespace: 'theme/v1', route: '/products', method: 'GET', permission: 'public', )] public function list(WP_REST_Request $request): WP_REST_Response { // No authentication required } ``` ### Protected Endpoint ```php #[AsRestRoute( namespace: 'theme/v1', route: '/orders', method: 'GET', permission: 'canViewOrders', )] public function listOrders(WP_REST_Request $request): WP_REST_Response { // Only authenticated users with permission } public function canViewOrders(WP_REST_Request $request): bool { return current_user_can('read'); } ``` ### With Arguments Schema ```php #[AsRestRoute( namespace: 'theme/v1', route: '/products', method: 'GET', args: [ 'per_page' => [ 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 100, ], 'category' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], ], )] public function list(WP_REST_Request $request): WP_REST_Response { $perPage = $request->get_param('per_page'); $category = $request->get_param('category'); // ... } ``` ### Multiple Methods ```php #[AsRestRoute(namespace: 'theme/v1', route: '/items', method: 'GET')] public function index(WP_REST_Request $request): WP_REST_Response {} #[AsRestRoute(namespace: 'theme/v1', route: '/items', method: 'POST')] public function store(WP_REST_Request $request): WP_REST_Response {} #[AsRestRoute(namespace: 'theme/v1', route: '/items/(?P\d+)', method: 'PUT')] public function update(WP_REST_Request $request): WP_REST_Response {} #[AsRestRoute(namespace: 'theme/v1', route: '/items/(?P\d+)', method: 'DELETE')] public function destroy(WP_REST_Request $request): WP_REST_Response {} ``` ## Related * [Guide: REST API](/guide/rest-api) --- --- url: /foehn-framework/api/as-rewrite-rule.md --- # #\[AsRewriteRule] Registers a WordPress rewrite rule, and the class that answers it. ## Signature ```php ` | `[]` | Query variables to register through the `query_vars` filter | | `after` | `string` | `'top'` | `'top'` or `'bottom'`. Anything else is refused during discovery | ## RewriteHandlerInterface ```php */ public static function settings(): array; } ``` Required. A class carrying the attribute without it is refused during discovery, because there is nothing to register. ## The form body A page supplies it in one of two ways, and discovery refuses a page that supplies neither. ### A Twig template `template: 'settings/theme-settings'` on the attribute. The template is rendered through `ViewEngineInterface` and receives: | Variable | Contents | | ---------- | ------------------------------------------------------------- | | `settings` | The current value of each declared setting, typed as declared | | `page` | `slug` and `title` | ### SettingsFormInterface ```php '#', 'class' => 'btn', ], $atts); return sprintf( '%s', esc_url($atts['url']), esc_attr($atts['class']), esc_html($content ?? 'Click') ); } ``` **Usage:** `[button url="https://example.com"]Learn More[/button]` ### Enclosing Shortcode ```php #[AsShortcode('spoiler')] public function spoiler(array $atts, ?string $content = null): string { $atts = shortcode_atts(['title' => 'Spoiler'], $atts); return sprintf( '
    %s%s
    ', esc_html($atts['title']), do_shortcode($content ?? '') ); } ``` **Usage:** ``` [spoiler title="Click to reveal"] Hidden content here. [/spoiler] ``` ### With Template ```php public function __construct( private readonly ViewEngineInterface $view, ) {} #[AsShortcode('testimonial')] public function testimonial(array $atts): string { $atts = shortcode_atts(['id' => 0], $atts); $post = \Timber\Timber::get_post($atts['id']); if (!$post) { return ''; } return $this->view->render('shortcodes/testimonial', [ 'testimonial' => $post, ]); } ``` ## Security Always escape shortcode output to prevent XSS vulnerabilities: ```php #[AsShortcode('user_card')] public function userCard(array $atts): string { $atts = shortcode_atts([ 'name' => '', 'bio' => '', 'url' => '#', ], $atts); return sprintf( '
    %s

    %s

    ', esc_url($atts['url']), // URLs esc_html($atts['name']), // Plain text wp_kses_post($atts['bio']) // Rich HTML ); } ``` See [Security Guide](/guide/security) for detailed escaping guidance. ## Related * [Guide: Shortcodes](/guide/shortcodes) * [Guide: Security](/guide/security) --- --- url: /foehn-framework/api/as-taxonomy.md --- # #\[AsTaxonomy] Register a class as a custom WordPress taxonomy. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsTaxonomy { public function __construct( public string $name, public array $postTypes = [], public ?string $singular = null, public ?string $plural = null, public bool $public = true, public bool $hierarchical = false, public bool $showInRest = true, public bool $showAdminColumn = true, public ?string $rewriteSlug = null, public array $labels = [], public array|false|null $rewrite = null, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | ----------------- | ----------------------- | ------- | -------------------------------------------------- | | `name` | `string` | — | Taxonomy slug (required) | | `postTypes` | `string[]` | `[]` | Associated post type slugs | | `singular` | `?string` | `null` | Singular label | | `plural` | `?string` | `null` | Plural label | | `public` | `bool` | `true` | Whether publicly visible | | `hierarchical` | `bool` | `false` | Hierarchical like categories | | `showInRest` | `bool` | `true` | Enable REST API and Gutenberg | | `showAdminColumn` | `bool` | `true` | Show column in admin post list | | `rewriteSlug` | `?string` | `null` | Custom URL slug (shorthand for `rewrite`) | | `labels` | `array` | `[]` | Custom labels (merged with auto-generated ones) | | `rewrite` | `array\|false\|null` | `null` | Full rewrite config, `false` to disable, or `null` | ## Usage ### Basic Taxonomy ```php setCapabilities([ 'manage_terms' => 'manage_skills', 'edit_terms' => 'edit_skills', 'delete_terms' => 'delete_skills', 'assign_terms' => 'assign_skills', ]); } } ``` ## Related * [Guide: Taxonomies](/guide/taxonomies) * [`#[AsPostType]`](./as-post-type) --- --- url: /foehn-framework/api/as-template-controller.md --- # #\[AsTemplateController] Register a class as a template controller that handles full template rendering. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsTemplateController { public function __construct( public string|array $templates, public int $priority = 10, ) {} public function getTemplates(): array {} } ``` ## Parameters | Parameter | Type | Default | Description | | ----------- | --------------- | ------- | --------------------------------------- | | `templates` | `string\|array` | — | Template pattern(s) to match (required) | | `priority` | `int` | `10` | Priority for template\_include filter | ## Template Patterns Uses WordPress template hierarchy names: * `'single'` — Single posts * `'single-product'` — Single product posts * `'archive'` — Archive pages * `'category'` — Category archives * `'home'` — Blog home * `'front-page'` — Static front page * `'404'` — Not found * `'single-*'` — Wildcard pattern * `['home', 'front-page']` — Multiple templates ## Usage ### Basic Controller ```php view->render('single', [ 'post' => $post, ]); } } ``` ### Return Null to Pass Through Return `null` to let WordPress handle the template: ```php #[AsTemplateController('single')] final class SingleController implements TemplateControllerInterface { public function handle(): ?string { $post = \Timber\Timber::get_post(); // Only handle products if ($post->post_type !== 'product') { return null; } return $this->view->render('single-product', [ 'post' => $post, ]); } } ``` ### Multiple Templates ```php #[AsTemplateController(['home', 'front-page'])] final class HomeController implements TemplateControllerInterface { public function handle(): ?string { return $this->view->render('pages/home', [ 'featured' => $this->getFeaturedPosts(), ]); } } ``` ### Wildcard Pattern ```php #[AsTemplateController('archive-*')] final class ArchiveController implements TemplateControllerInterface { public function handle(): ?string { return $this->view->render('archive', [ 'posts' => \Timber\Timber::get_posts(), 'pagination' => \Timber\Timber::get_pagination(), ]); } } ``` ## Required Interface Classes must implement `TemplateControllerInterface`: ```php interface TemplateControllerInterface { public function handle(): ?string; } ``` ## Related * [Guide: Template Controllers](/guide/template-controllers) * [`TemplateControllerInterface`](./template-controller-interface) * [`#[AsContextProvider]`](./as-context-provider) --- --- url: /foehn-framework/api/as-timber-model.md --- # #\[AsTimberModel] Register a Timber class map for a post type or taxonomy without registering the type itself. ## Signature ```php content())); return max(1, (int) ceil($wordCount / 200)); } /** * Check if the post has a featured video. */ public function hasFeaturedVideo(): bool { return !empty($this->meta('featured_video_url')); } } ``` ### Extending Pages ```php 'page', 'post_parent' => $this->ID, 'orderby' => 'menu_order', 'order' => 'ASC', ]); } /** * Check if this is a parent page. */ public function hasChildren(): bool { return count($this->children()) > 0; } } ``` ### Extending Categories ```php meta('icon') ?: null; } /** * Get the category color from ACF. */ public function color(): string { return $this->meta('color') ?: '#333333'; } } ``` ### Extending WooCommerce Products ```php ID) ?: null; } /** * Get formatted price. */ public function formattedPrice(): string { $product = $this->wcProduct(); return $product ? $product->get_price_html() : ''; } } ``` ## Usage in Templates Once mapped, Timber automatically uses your class: ```twig {# single.twig #}

    {{ post.title }}

    {{ post.readingTime }} min read

    {% if post.hasFeaturedVideo %} {# Show video player #} {% endif %} {{ post.content }}
    ``` ```twig {# archive.twig #} {% for post in posts %} {% set categories = post.terms('category') %} {% for category in categories %} {{ category.icon }} {{ category.name }} {% endfor %} {% endfor %} ``` ## Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | --------------------------------- | | `name` | `string` | Yes | Post type or taxonomy slug to map | ## Requirements The class must extend one of: * `Timber\Post` for post types * `Timber\Term` for taxonomies ## See Also * [Guide: Post Types](/guide/post-types) — Custom post types with `#[AsPostType]` * [Guide: Taxonomies](/guide/taxonomies) — Custom taxonomies with `#[AsTaxonomy]` * [Timber Documentation](https://timber.github.io/docs/v2/guides/class-maps/) --- --- url: /foehn-framework/api/as-twig-extension.md --- # #\[AsTwigExtension] Register a class as a Twig extension for Timber templates. ## Signature ```php #[Attribute(Attribute::TARGET_CLASS)] final readonly class AsTwigExtension { public function __construct( public int $priority = 10, ) {} } ``` ## Parameters | Parameter | Type | Default | Description | | ---------- | ----- | ------- | ------------------------------------------ | | `priority` | `int` | `10` | Loading priority (lower values load first) | ## Requirements The class must extend `Twig\Extension\AbstractExtension`. ## Usage ### Basic Usage ```php "Hello, {$name}!"), ]; } } ``` Then use it in your templates: ```twig {{ hello('World') }} {# Output: Hello, World! #} ``` ### Adding Filters ```php {{ post.content | excerpt(200) }}

    {{ post.content | reading_time }} min read ``` ### With Priority Control the order extensions are loaded: ```php #[AsTwigExtension(priority: 5)] final class CoreExtension extends AbstractExtension { // Loads before extensions with default priority (10) } #[AsTwigExtension(priority: 20)] final class OverrideExtension extends AbstractExtension { // Loads after default priority extensions } ``` ### With Dependency Injection Extensions are resolved through the container, so you can inject dependencies: ```php formatter, 'format']), ]; } } ``` ## Built-in Extensions Føhn provides built-in extensions that you can use as examples: * `InteractivityExtension` — Helpers for WordPress Interactivity API * `VideoEmbedExtension` — Video URL transformation utilities ## Related * [Guide: Twig Extensions](/guide/twig-extensions) * [Twig Documentation: Extending Twig](https://twig.symfony.com/doc/3.x/advanced.html) --- --- url: /foehn-framework/guide/acf-blocks.md --- # ACF Blocks Føhn provides `#[AsAcfBlock]` for creating ACF blocks with type-safe fields using `stoutlogic/acf-builder`. ## Requirements ACF is an optional package. Custom fields do not need it: [`#[AsPostMeta]`](/api/as-post-meta) registers a meta key with a REST schema, which is what puts a field in the block editor and makes it bindable. ACF is the better answer when the editing UI matters — repeaters, flexible content, conditional logic, media pickers. ```bash composer require studiometa/foehn-acf ``` * [ACF Pro](https://www.advancedcustomfields.com/pro/) installed and active * `studiometa/foehn-acf`, which brings `stoutlogic/acf-builder` with it Nothing registers when ACF is absent: each discovery guards on the function it needs. The classes keep their `Studiometa\Foehn\` namespaces, so a project upgrading from 0.4 changes one Composer requirement and no imports. ## Basic ACF Block ```php addText('title', ['label' => 'Title']) ->addWysiwyg('content', ['label' => 'Content']) ->addImage('background', ['label' => 'Background Image']); } public function compose(array $block, array $fields): array { return [ 'title' => $fields['title'] ?? '', 'content' => $fields['content'] ?? '', 'background' => $fields['background'] ?? null, 'block_id' => $block['id'] ?? '', ]; } public function render(array $context, bool $isPreview = false): string { return $this->view->render('blocks/hero', $context); } } ``` ## Template ```twig {# templates/blocks/hero.twig #}
    {% if background %} {{ background.alt }} {% endif %}
    {% if title %}

    {{ title }}

    {% endif %} {% if content %}
    {{ content }}
    {% endif %}
    ``` ## Field Values `$fields` holds what [`get_fields()`](https://www.advancedcustomfields.com/resources/get_fields/) returns for the block. ACF loads the block's data before it calls the render callback, so the values are formatted and nested like the values of a post: ```php [ 'title' => 'Hello', 'show_cta' => true, 'meta' => ['label' => 'New', 'icon' => $icon], // Timber\Image 'items' => [ ['heading' => 'First', 'picture' => $picture], // Timber\Image ['heading' => 'Second', 'picture' => false], ], ] ``` Do not read `$block['data']`. It is what ACF stores in the block comment: flat and unformatted (`items: 2`, `items_0_heading`, `meta_label`, image IDs, `"1"` for a true/false), or keyed by field key in a block template. ## Automatic Field Transformation By default, Føhn automatically transforms ACF field values into Timber objects. This means you don't need to manually convert image IDs to `Timber\Image`, post IDs to `Timber\Post`, etc. While it reads the block's fields, Føhn replaces ACF's formatting of the types below with Timber's ACF transforms, the way Timber does for `$post->meta($name, ['transform_value' => true])`. The other types keep ACF's formatting. A `get_field()` call in the block's own code still returns ACF's formatting. ### Enabled by Default Field transformation is enabled by default. To disable it, create an ACF config file. Every field then has ACF's formatting: an image is what its `return_format` gives, an array by default. ```php {# Gallery fields #} {% for item in gallery %} {{ item.alt }} {% endfor %} {# Relationship fields #} {% for post in related_posts %} {{ post.title }} {% endfor %} {# Date fields #} ``` ## Full Configuration ```php #[AsAcfBlock( name: 'testimonial', title: 'Testimonial', category: 'text', icon: 'format-quote', description: 'Display a customer testimonial', keywords: ['quote', 'review', 'customer'], mode: 'preview', supports: [ 'align' => true, 'mode' => true, 'jsx' => true, ], postTypes: ['page', 'post'], )] final readonly class TestimonialBlock implements AcfBlockInterface {} ``` ## Complex Fields Example ```php addText('title', ['label' => 'Section Title']) ->addTextarea('description', ['label' => 'Section Description']) ->addRepeater('features', ['label' => 'Features', 'layout' => 'block']) ->addImage('icon', ['label' => 'Icon']) ->addText('title', ['label' => 'Feature Title']) ->addTextarea('description', ['label' => 'Feature Description']) ->addLink('link', ['label' => 'Link']) ->endRepeater() ->addSelect('columns', [ 'label' => 'Columns', 'choices' => [ '2' => '2 Columns', '3' => '3 Columns', '4' => '4 Columns', ], 'default_value' => '3', ]); return $builder; } public function compose(array $block, array $fields): array { return [ 'title' => $fields['title'] ?? '', 'description' => $fields['description'] ?? '', 'features' => $fields['features'] ?? [], 'columns' => $fields['columns'] ?? '3', ]; } public function render(array $context, bool $isPreview = false): string { return $this->view->render('blocks/features', $context); } } ``` ## Conditional Fields ```php public static function fields(): FieldsBuilder { $builder = new FieldsBuilder('cta'); $builder ->addText('title') ->addSelect('button_type', [ 'choices' => [ 'link' => 'Link', 'download' => 'Download', 'modal' => 'Modal', ], ]) ->addLink('link') ->conditional('button_type', '==', 'link') ->addFile('file') ->conditional('button_type', '==', 'download') ->addText('modal_id') ->conditional('button_type', '==', 'modal'); return $builder; } ``` ## Tabs and Groups ```php public static function fields(): FieldsBuilder { $builder = new FieldsBuilder('card'); $builder ->addTab('Content') ->addText('title') ->addWysiwyg('content') ->addImage('image') ->addTab('Settings') ->addSelect('style', [ 'choices' => ['default', 'featured', 'minimal'], ]) ->addColorPicker('background_color') ->addTrueFalse('show_shadow'); ->addTab('Link') ->addLink('link'); return $builder; } ``` ## Field Validation Føhn provides a `ValidatesFields` trait for optional field validation and sanitization in your `compose()` method. ### Using the Trait ```php addText('title') ->addWysiwyg('content') ->addNumber('count'); } public function compose(array $block, array $fields): array { // Validate required fields (throws InvalidArgumentException if missing) $this->validateRequired($fields, ['title']); // Sanitize individual fields return [ 'title' => $this->sanitizeField($fields['title'], 'string'), 'content' => $this->sanitizeField($fields['content'] ?? '', 'html'), 'count' => $this->sanitizeField($fields['count'] ?? 0, 'int'), ]; } public function render(array $context, bool $isPreview = false): string { return $this->view->render('blocks/hero', $context); } } ``` ### Schema-Based Validation For more complex validation, use `validateFields()` with a schema: ```php public function compose(array $block, array $fields): array { return $this->validateFields($fields, [ 'title' => ['type' => 'string', 'required' => true], 'content' => ['type' => 'html', 'default' => ''], 'count' => ['type' => 'int', 'default' => 0], 'email' => ['type' => 'email'], 'link' => ['type' => 'url'], 'items' => ['type' => 'array', 'default' => []], ]); } ``` ### Available Methods | Method | Description | | -------------------------------------------------- | ----------------------------------------------- | | `validateRequired(array $fields, array $required)` | Throws if required fields are missing or empty | | `validateType(mixed $value, string $type)` | Returns `true` if value matches expected type | | `sanitizeField(mixed $value, string $type)` | Coerces value to expected type | | `validateFields(array $fields, array $schema)` | Validates and sanitizes fields against a schema | ### Supported Types | Type | Description | | -------- | -------------------------------------------------- | | `string` | Trimmed string | | `int` | Integer (coerced from numeric strings) | | `float` | Float (coerced from numeric values) | | `bool` | Boolean (handles `'true'`, `'yes'`, `'1'`, `'on'`) | | `array` | Array | | `html` | HTML content (sanitized via `wp_kses_post`) | | `email` | Email address (sanitized) | | `url` | URL (sanitized via `esc_url_raw`) | ### Advanced Validation For more advanced validation needs, consider using: * [`webmozart/assert`](https://github.com/webmozarts/assert) - Simple assertions * [`respect/validation`](https://github.com/Respect/Validation) - Fluent validation API (Zod-like) ```php use Webmozart\Assert\Assert; public function compose(array $block, array $fields): array { Assert::stringNotEmpty($fields['title'] ?? ''); Assert::nullOrInteger($fields['count'] ?? null); return $fields; } ``` ## Preview Mode Handle preview mode differently: ```php public function render(array $context, bool $isPreview = false): string { if ($isPreview && empty($context['title'])) { return '
    Please add content
    '; } return $this->view->render('blocks/hero', $context); } ``` ## File Structure Organize blocks with their templates: ``` app/Blocks/ ├── Hero/ │ └── HeroBlock.php ├── Features/ │ └── FeaturesBlock.php ├── Testimonial/ │ └── TestimonialBlock.php └── Cta/ └── CtaBlock.php templates/blocks/ ├── hero.twig ├── features.twig ├── testimonial.twig └── cta.twig ``` ## Attribute Parameters | Parameter | Type | Default | Description | | ------------- | ---------- | ----------- | ---------------------------------- | | `name` | `string` | *required* | Block name (without `acf/` prefix) | | `title` | `string` | *required* | Display title | | `category` | `string` | `'widgets'` | Block category | | `icon` | `?string` | `null` | Dashicon or SVG | | `description` | `?string` | `null` | Block description | | `keywords` | `string[]` | `[]` | Search keywords | | `mode` | `string` | `'preview'` | `'preview'`, `'edit'`, or `'auto'` | | `supports` | `array` | `[]` | Block supports | | `template` | `?string` | `null` | Custom template path | | `postTypes` | `string[]` | `[]` | Allowed post types | | `parent` | `?string` | `null` | Parent block name | ## See Also * [Native Blocks](./native-blocks) * [Block Patterns](./block-patterns) * [API Reference: #\[AsAcfBlock\]](/api/as-acf-block) * [API Reference: AcfBlockInterface](/api/acf-block-interface) --- --- url: /foehn-framework/guide/field-fragments.md --- # ACF Field Fragments ::: tip Field fragments ship in `studiometa/foehn-acf`, the optional ACF package. See [ACF Blocks](/guide/acf-blocks#requirements). ::: Field Fragments are reusable ACF field groups that can be shared across multiple blocks. By extending `FieldsBuilder`, you create self-contained field definitions that you add to any block's fields with acf-builder's `addFields()`. ## Why Use Field Fragments? When building ACF blocks, you often repeat the same field patterns: * Button/link with text, URL, target, and style * Responsive images with mobile/desktop variants * Spacing controls (margin, padding) * Background settings (color, image, overlay) Instead of duplicating these fields in every block, create a **Field Fragment** once and reuse it everywhere. ## Built-in Fragments Føhn provides common fragments out of the box: | Fragment | Description | Field names | | ------------------------ | ------------------------------------ | ------------------------------------------------------ | | `ButtonLinkBuilder` | Link with style and size options | `link`, `style`, `size` | | `ResponsiveImageBuilder` | Desktop/mobile image variants | `desktop`, `mobile` | | `SpacingBuilder` | Padding top/bottom controls | `top`, `bottom` | | `BackgroundBuilder` | Color, image, and overlay background | `type`, `color`, `image`, `overlay`, `overlay_opacity` | ```php use Studiometa\Foehn\Acf\Fragments\ButtonLinkBuilder; use Studiometa\Foehn\Acf\Fragments\ResponsiveImageBuilder; use Studiometa\Foehn\Acf\Fragments\SpacingBuilder; use Studiometa\Foehn\Acf\Fragments\BackgroundBuilder; ``` ## Creating Custom Fragments A Field Fragment extends `FieldsBuilder` and configures its fields in the constructor: ```php $label]); $this ->addOembed('url', [ 'label' => 'Video URL', 'instructions' => 'YouTube or Vimeo URL', ]) ->addImage('poster', [ 'label' => 'Poster Image', 'instructions' => 'Custom thumbnail (optional)', 'return_format' => 'id', ]) ->addTrueFalse('autoplay', [ 'label' => 'Autoplay', 'default_value' => false, ]); } } ``` ## Using Fragments in Blocks Use acf-builder's `addFields()` to add a fragment to your block's field configuration. `addFields()` copies the fragment's fields into the builder, with the field names that the fragment gives them. The first constructor argument of a fragment (`'cta'` in `new ButtonLinkBuilder('cta')`) names the fragment's own builder. It does **not** prefix the field names: every `ButtonLinkBuilder` creates `link`, `style` and `size`. Wrap each fragment in `addGroup()` so that its fields have their own namespace: ```php addWysiwyg('content', ['label' => 'Content']) // Add the built-in fragments, each one in its own group ->addGroup('cta', ['label' => 'Call to Action']) ->addFields(new ButtonLinkBuilder()) ->endGroup() ->addGroup('background', ['label' => 'Background']) ->addFields(new BackgroundBuilder()) ->endGroup(); return $builder; } // ... } ``` This produces: * `content` (wysiwyg) * `cta` (group) * `link` (link) * `style` (select) * `size` (select) * `background` (group) * `type` (button\_group) * `color` (color\_picker) * `image` (image) * `overlay` (true\_false) * `overlay_opacity` (range) The block values are nested in the same way: `$fields['cta']['link']`, `$fields['background']['type']`. The conditional logic inside a fragment keeps working in a group, because acf-builder resolves it against the field keys of the group. Without the groups, two fragments that share a field name throw a `FieldNameCollisionException` when you add the second one. For example, two `ButtonLinkBuilder` fragments in the same builder both create `link`. ## Customizing Built-in Fragments All built-in fragments accept constructor parameters for customization. The examples below continue a `$builder` chain like the one in `HeroBlock`. ### ButtonLinkBuilder ```php use Studiometa\Foehn\Acf\Fragments\ButtonLinkBuilder; // Default usage ->addGroup('button') ->addFields(new ButtonLinkBuilder()) ->endGroup() // Custom styles, no size field ->addGroup('cta', ['label' => 'Call to Action']) ->addFields(new ButtonLinkBuilder( name: 'cta', label: 'Call to Action', styles: ['primary' => 'Primary', 'ghost' => 'Ghost'], sizes: null, // Disable size field required: true, )) ->endGroup() ``` ### ResponsiveImageBuilder ```php use Studiometa\Foehn\Acf\Fragments\ResponsiveImageBuilder; // Default usage ->addGroup('image') ->addFields(new ResponsiveImageBuilder()) ->endGroup() // With custom instructions ->addGroup('hero_image', ['label' => 'Hero Image']) ->addFields(new ResponsiveImageBuilder( name: 'hero_image', label: 'Hero Image', required: true, desktopInstructions: 'Recommended: 2560×1440px', mobileInstructions: 'Recommended: 750×1334px', )) ->endGroup() ``` ### SpacingBuilder ```php use Studiometa\Foehn\Acf\Fragments\SpacingBuilder; // Default usage ->addGroup('spacing') ->addFields(new SpacingBuilder()) ->endGroup() // Custom sizes and labels ->addGroup('margin', ['label' => 'Margins']) ->addFields(new SpacingBuilder( name: 'margin', label: 'Margins', sizes: ['0' => 'None', '1' => 'Small', '2' => 'Medium', '3' => 'Large'], default: '1', topLabel: 'Margin Top', bottomLabel: 'Margin Bottom', )) ->endGroup() ``` ### BackgroundBuilder ```php use Studiometa\Foehn\Acf\Fragments\BackgroundBuilder; // Default usage ->addGroup('background') ->addFields(new BackgroundBuilder()) ->endGroup() // Image-only background (no color option) ->addGroup('bg', ['label' => 'Background']) ->addFields(new BackgroundBuilder( name: 'bg', label: 'Background', types: ['none' => 'None', 'image' => 'Image'], default: 'none', defaultOpacity: 70, )) ->endGroup() ``` ## Organizing Fragments with Tabs Fragments work well inside tabs for better editor UX: ```php public static function fields(): FieldsBuilder { $builder = new FieldsBuilder('hero'); $builder ->addTab('Content') ->addText('title') ->addWysiwyg('content') ->addGroup('cta', ['label' => 'Call to Action']) ->addFields(new ButtonLinkBuilder()) ->endGroup() ->addTab('Media') ->addGroup('hero_image', ['label' => 'Hero Image']) ->addFields(new ResponsiveImageBuilder(required: true)) ->endGroup() ->addTab('Settings') ->addGroup('spacing', ['label' => 'Spacing']) ->addFields(new SpacingBuilder()) ->endGroup() ->addGroup('background', ['label' => 'Background']) ->addFields(new BackgroundBuilder()) ->endGroup(); return $builder; } ``` `addTab()` returns the new tab field, not `$builder`. Keep the chain on a `$builder` variable and return the variable. ## File Structure Organize fragments in a dedicated directory: ``` app/ ├── Acf/ │ └── Fragments/ │ ├── BackgroundBuilder.php │ ├── ButtonLinkBuilder.php │ ├── ResponsiveImageBuilder.php │ └── SpacingBuilder.php │ ├── Blocks/ │ ├── Hero/ │ │ └── HeroBlock.php │ └── Features/ │ └── FeaturesBlock.php ``` ## Best Practices ### 1. Use Constructor Parameters for Customization Allow fragments to be configured when instantiated: ```php final class ButtonLinkBuilder extends FieldsBuilder { public function __construct( string $name = 'button', string $label = 'Button', array $styles = ['primary', 'secondary'], bool $required = false, ) { parent::__construct($name, ['label' => $label]); $this ->addLink('link', [ 'label' => 'Link', 'required' => $required, ]) ->addSelect('style', [ 'label' => 'Style', 'choices' => array_combine($styles, array_map('ucfirst', $styles)), ]); } } ``` ### 2. Keep Fragments Focused Each fragment should handle one concern. Prefer multiple small fragments over one large one: ```php // ✅ Good: focused fragments ->addGroup('cta') ->addFields(new ButtonLinkBuilder()) ->endGroup() ->addGroup('spacing') ->addFields(new SpacingBuilder()) ->endGroup() // ❌ Avoid: kitchen-sink fragment ->addFields(new ButtonWithSpacingAndBackgroundBuilder()) ``` ### 3. Document Field Names Fragments do not prefix field names, so document the names that a fragment creates: ```php /** * Creates the following fields: * - link (link) * - style (select) * - size (select) - optional */ final class ButtonLinkBuilder extends FieldsBuilder ``` ### 4. Use Static Factory Methods for Presets For common configurations, add static factory methods to your own fragment: ```php final class ButtonLinkBuilder extends FieldsBuilder { // Constructor from practice 1... public static function primary(string $name = 'cta'): self { return new self($name, 'Call to Action', ['primary', 'secondary']); } } // Usage ->addGroup('cta') ->addFields(ButtonLinkBuilder::primary()) ->endGroup() ``` ## See Also * [ACF Blocks](./acf-blocks) — Creating ACF blocks with `#[AsAcfBlock]` * [acf-builder documentation](https://github.com/StoutLogic/acf-builder) — Full FieldsBuilder API --- --- url: /foehn-framework/guide/acf-options-pages.md --- # ACF Options Pages Føhn provides `#[AsAcfOptionsPage]` for creating ACF options pages with type-safe fields using `stoutlogic/acf-builder`. ## Requirements ACF is an optional package. Custom fields do not need it: [`#[AsPostMeta]`](/api/as-post-meta) registers a meta key with a REST schema, which is what puts a field in the block editor and makes it bindable. ACF is the better answer when the editing UI matters — repeaters, flexible content, conditional logic, media pickers. ```bash composer require studiometa/foehn-acf ``` * [ACF Pro](https://www.advancedcustomfields.com/pro/) installed and active * `studiometa/foehn-acf`, which brings `stoutlogic/acf-builder` with it. It requires the exact version of `studiometa/foehn` you have: upgrade the two packages together Nothing registers when ACF is absent: each discovery guards on the function it needs. The classes keep their `Studiometa\Foehn\` namespaces, so a project upgrading from 0.4 changes one Composer requirement and no imports. ## Basic Options Page ```php addText('site_name', ['label' => 'Site Name']) ->addTextarea('footer_text', ['label' => 'Footer Text']) ->addImage('logo', ['label' => 'Site Logo']); } } ``` ## Retrieving Option Values ### Using AcfOptionsService ```php with('site_name', $this->options->get('site_name', 'theme-settings')) ->with('footer_text', $this->options->get('footer_text', 'theme-settings')) ->with('logo', $this->options->get('logo', 'theme-settings')); } } ``` ### Using ACF Functions Directly ```php // Get a single field $siteName = get_field('site_name', 'theme-settings'); // Get all fields $settings = get_fields('theme-settings'); ``` ## Sub-Pages Create child pages under a parent options page: ```php addUrl('facebook', ['label' => 'Facebook URL']) ->addUrl('twitter', ['label' => 'Twitter URL']) ->addUrl('instagram', ['label' => 'Instagram URL']) ->addUrl('linkedin', ['label' => 'LinkedIn URL']); } } ``` ## Full Configuration ```php #[AsAcfOptionsPage( pageTitle: 'Advanced Settings', menuTitle: 'Advanced', menuSlug: 'advanced-settings', capability: 'manage_options', position: 59, iconUrl: 'dashicons-admin-settings', redirect: false, postId: 'advanced_options', autoload: true, updateButton: 'Save Settings', updatedMessage: 'Settings saved successfully!', )] ``` ## Options Without Fields You can create options pages without implementing `AcfOptionsPageInterface`. This is useful when fields are defined elsewhere or via the ACF UI: ```php get('field_name', 'options-page-slug'); // Get all fields from an options page $allFields = $options->all('options-page-slug'); // Check if a field has a value if ($options->has('field_name', 'options-page-slug')) { // Field has a non-empty value } // Get field object with metadata $fieldObject = $options->getObject('field_name', 'options-page-slug'); ``` ## Using in Twig Templates ```twig {# Get options in a context provider and pass to template #}

    {{ footer_text }}

    {% if logo %} {{ site_name }} {% endif %}
    ``` ## Related * [API: #\[AsAcfOptionsPage\]](/api/as-acf-options-page) * [API: AcfOptionsPageInterface](/api/acf-options-page-interface) * [Guide: ACF Blocks](/guide/acf-blocks) * [Guide: Context Providers](/guide/context-providers) --- --- url: /foehn-framework/api/acf-block-interface.md --- # AcfBlockInterface Interface for ACF (Advanced Custom Fields) blocks. ## Signature ```php $block Block data from ACF * @param array $fields Field values from get_fields() * @return array|Arrayable Context for the template */ public function compose(array $block, array $fields): array|Arrayable; /** * Render the block. * * @param array $context Composed context * @param bool $isPreview Whether rendering in editor preview * @return string Rendered HTML */ public function render(array $context, bool $isPreview = false): string; } ``` ## Methods ### fields() Define ACF fields using `stoutlogic/acf-builder`. This is a static method called during registration. ```php public static function fields(): FieldsBuilder { return (new FieldsBuilder('hero')) ->addText('title', ['label' => 'Title']) ->addWysiwyg('content', ['label' => 'Content']) ->addImage('background', ['label' => 'Background']); } ``` ### compose() Transform ACF field values into template context. ```php public function compose(array $block, array $fields): array { return [ 'title' => $fields['title'] ?? '', 'content' => $fields['content'] ?? '', 'background' => $fields['background'] ?? null, 'block_id' => $block['id'] ?? '', 'block_classes' => $block['className'] ?? '', ]; } ``` ### render() Render the block HTML. Receives the composed context and preview flag. ```php public function render(array $context, bool $isPreview = false): string { // Handle empty state in preview if ($isPreview && empty($context['title'])) { return '
    Add content
    '; } return $this->view->render('blocks/hero', $context); } ``` ## Usage ```php addText('title') ->addWysiwyg('content') ->addImage('background'); } public function compose(array $block, array $fields): array { return [ 'title' => $fields['title'] ?? '', 'content' => $fields['content'] ?? '', 'background' => $fields['background'] ?? null, ]; } public function render(array $context, bool $isPreview = false): string { return $this->view->render('blocks/hero', $context); } } ``` ## Complex Fields Example ```php public static function fields(): FieldsBuilder { return (new FieldsBuilder('features')) ->addTab('Content') ->addText('title') ->addTextarea('description') ->addRepeater('items', ['layout' => 'block']) ->addImage('icon') ->addText('title') ->addTextarea('text') ->endRepeater() ->addTab('Settings') ->addSelect('columns', [ 'choices' => [ '2' => '2 Columns', '3' => '3 Columns', '4' => '4 Columns', ], ]) ->addColorPicker('background_color'); } ``` ## Validation Use the `ValidatesFields` trait to add optional validation in your `compose()` method: ```php use Studiometa\Foehn\Blocks\Concerns\ValidatesFields; final readonly class HeroBlock implements AcfBlockInterface { use ValidatesFields; public function compose(array $block, array $fields): array { // Simple: validate required and sanitize $this->validateRequired($fields, ['title']); return [ 'title' => $this->sanitizeField($fields['title'], 'string'), 'count' => $this->sanitizeField($fields['count'] ?? 0, 'int'), ]; // Or use schema-based validation return $this->validateFields($fields, [ 'title' => ['type' => 'string', 'required' => true], 'count' => ['type' => 'int', 'default' => 0], ]); } } ``` See [Field Validation](/guide/acf-blocks#field-validation) for full documentation. ## Related * [Guide: ACF Blocks](/guide/acf-blocks) * [`#[AsAcfBlock]`](./as-acf-block) --- --- url: /foehn-framework/api/acf-config.md --- # AcfConfig Configuration class for ACF (Advanced Custom Fields) integration. ## Signature ```php addText('sku', ['label' => 'SKU']) ->addNumber('price', ['label' => 'Price']) ->addWysiwyg('description', ['label' => 'Description']); } ``` ## Usage ```php 'product'], )] final class ProductFields implements AcfFieldGroupInterface { public static function fields(): FieldsBuilder { return (new FieldsBuilder('product_fields')) ->addText('sku', ['label' => 'SKU']) ->addNumber('price', ['label' => 'Price']) ->addWysiwyg('description', ['label' => 'Description']); } } ``` ## Complex Fields Example ```php public static function fields(): FieldsBuilder { return (new FieldsBuilder('property_fields')) ->addTab('General') ->addText('external_id', ['label' => 'External ID']) ->addText('address', ['label' => 'Address']) ->addNumber('bedrooms', ['label' => 'Bedrooms']) ->addNumber('bathrooms', ['label' => 'Bathrooms']) ->addTab('Media') ->addGallery('photos', ['label' => 'Photos']) ->addOembed('video_tour', ['label' => 'Video Tour']) ->addTab('Features') ->addRepeater('features', ['layout' => 'table']) ->addText('name') ->addText('value') ->endRepeater() ->addTab('Pricing') ->addNumber('price', ['label' => 'Price']) ->addSelect('status', [ 'label' => 'Status', 'choices' => [ 'available' => 'Available', 'pending' => 'Pending', 'sold' => 'Sold', ], ]); } ``` ## Comparison with AcfBlockInterface | Feature | `AcfFieldGroupInterface` | `AcfBlockInterface` | | ------------------ | -------------------------- | -------------------------------- | | Purpose | Post/page/taxonomy fields | Gutenberg block fields | | Methods | `fields()` only | `fields()`, `compose()`, `render()` | | Location | Set via attribute | Automatically set to block | | Rendering | WordPress handles display | Class handles rendering | ## Accessing Field Values Field values are accessed via standard ACF functions in your templates or code: ```php // In PHP $sku = get_field('sku'); $price = get_field('price'); // In Twig (with Timber) {{ post.meta('sku') }} {{ post.meta('price') }} // Or using ACF functions {{ function('get_field', 'sku') }} ``` ## Related * [`#[AsAcfFieldGroup]`](./as-acf-field-group) * [`AcfBlockInterface`](./acf-block-interface) * [`#[AsAcfBlock]`](./as-acf-block) --- --- url: /foehn-framework/api/acf-options-page-interface.md --- # AcfOptionsPageInterface Interface for ACF options pages that define fields programmatically. ## Signature ```php interface AcfOptionsPageInterface { public static function fields(): FieldsBuilder; } ``` ## Methods ### fields() Define ACF fields for this options page using `stoutlogic/acf-builder`. **Returns:** `FieldsBuilder` — The configured fields builder ## Usage ### Basic Implementation ```php addText('site_name', [ 'label' => 'Site Name', 'instructions' => 'Enter your site name', ]) ->addTextarea('footer_text', [ 'label' => 'Footer Text', 'rows' => 4, ]); } } ``` ### With Tabs and Groups ```php public static function fields(): FieldsBuilder { return (new FieldsBuilder('theme_settings')) ->addTab('general', ['label' => 'General']) ->addText('site_name') ->addImage('logo') ->addTab('social', ['label' => 'Social Media']) ->addUrl('facebook') ->addUrl('twitter') ->addUrl('instagram') ->addTab('footer', ['label' => 'Footer']) ->addWysiwyg('footer_content') ->addText('copyright'); } ``` ### With Repeater Fields ```php public static function fields(): FieldsBuilder { return (new FieldsBuilder('partners_settings')) ->addRepeater('partners', ['label' => 'Partners', 'layout' => 'block']) ->addText('name', ['label' => 'Partner Name']) ->addImage('logo', ['label' => 'Partner Logo']) ->addUrl('website', ['label' => 'Website URL']) ->endRepeater(); } ``` ### With Conditional Logic ```php public static function fields(): FieldsBuilder { return (new FieldsBuilder('display_settings')) ->addTrueFalse('show_banner', [ 'label' => 'Show Banner', 'default_value' => false, ]) ->addImage('banner_image', ['label' => 'Banner Image']) ->conditional('show_banner', '==', '1') ->addText('banner_text', ['label' => 'Banner Text']) ->conditional('show_banner', '==', '1'); } ``` ## When to Implement Implement this interface when you want to: * Define fields programmatically with type safety * Version control your field definitions * Share field configurations across environments ## When Not to Implement Skip implementing this interface when: * Fields are defined via ACF UI * Fields are imported from JSON * You're using a third-party ACF field group ```php #[AsAcfOptionsPage(pageTitle: 'External Settings')] final class ExternalSettings { // No interface needed - fields defined elsewhere } ``` ## Related * [Guide: ACF Options Pages](/guide/acf-options-pages) * [`#[AsAcfOptionsPage]`](./as-acf-options-page) * [`AcfBlockInterface`](./acf-block-interface) --- --- url: /foehn-framework/guide/admin-cache-controls.md --- # Admin Cache Controls Føhn adds one admin page and one admin-bar menu, so the two questions that come up at the worst moment can be answered without a shell: **what state is this installation actually in**, and **clear this page now**. Both are on by default. There is nothing to add to `foehn.config.php`: a project that had to switch on the page it would read the cache's state on has no way to find out that it needed to. ## The Føhn page A top-level **Føhn** entry in the admin menu, visible to users with `manage_options`. It reports: | Row | What it says | | --------------------- | -------------------------------------------------------------------------------------------- | | `WP_ENVIRONMENT_TYPE` | The environment as `Helpers\Env` resolves it, not as a file declares it. | | `WP_DEBUG` | Whether debug mode is on. | | Configured | The `enabled` flag from your [`PageCacheConfig`](/api/page-cache-config). | | Effective | **Active**, **Disabled**, or **enabled but unavailable in this environment**. | | Cache path | Where responses are stored. | | TTL | The configured lifetime, or "purge-driven" when it is `0`. | | Cached responses | How many stored response bodies exist right now. | | Section responses | How many of those are [section](/guide/section-rendering) fragments rather than whole pages. | | Total size | What the cache directory holds on disk, headers sidecars included. | | Last full purge | When the cache was last emptied in full. Per-URL and per-section clears are not recorded. | | Last real run | When the container's WP-Cron runner last succeeded, or **Never**. | The two environment rows are labelled with the constant names rather than with prose on purpose. When somebody reports that "it thinks it is staging", you both have to be looking at the same name. The **Effective** row is worth reading before anything else. `enabled: true` in an environment your config does not list writes nothing and serves nothing, and a page that reported only "enabled" would send you hunting a broken cache when what you have is a missing environment name. **Last real run is Never until the container's cron runner is in place.** That is the honest answer for a site with no real cron, and it is the same answer a site whose runner has broken gives — which is the reason the row exists. ## The two buttons on the page * **Clear the whole page cache** — every stored response, pages and fragments alike. * **Clear the section cache only** — the variants keyed by `foehn_sections`, leaving whole pages and unrelated keyed variants where they are. Both are plain `POST` forms. **This page is the no-JavaScript path**: it works with scripting off, which is what makes it the one to rely on when something else on the site is broken. ## The admin-bar menu A **Føhn Cache** node for `manage_options`, with three items: * **Clear whole cache** * **Clear section cache** * **Clear this page** — the current post, its cached 404, its keyed query variants and its section fragments. The third item appears **only** when WordPress itself supplies the post: a singular front-end request, or a post edit screen. Anywhere else it is absent rather than disabled, because an item that appears everywhere and works somewhere is one nobody learns to trust. It is also absent for a post no visitor could have been served — a draft has no cached page to clear. The node's own link goes to the Føhn page and nothing else. **The items do not mutate anything through their `href`**: each one submits a hidden, nonce-protected form rendered in the footer, through a few lines of inline script. A link that cleared a cache would be cleared by every prefetching browser, every link checker and every crawler holding a logged-in cookie, and no nonce fixes that — the browser is following the link on the user's behalf. With scripting off the items do nothing, which is the correct failure for a control that must not fire by accident; the page's buttons are still there. ## What the handlers require Every mutation goes through one of three `admin_post_` handlers, and each of them refuses a request that fails any of the following: 1. **The request must be a `POST`.** `admin-post.php` fires `admin_post_{action}` for a `GET` too, so this is a real check and not decoration. 2. **The user must have `manage_options`.** 3. **The nonce must have been minted for that action.** Each action has its own nonce action string, so the token on "clear everything" cannot authorise "clear this post". 4. **A post id, when one is present, must resolve to a real, publicly viewable post.** A refused request answers `403` and says nothing about which check failed. A successful one redirects — through `wp_safe_redirect()`, to a referrer WordPress itself validated or to the Føhn page — carrying only a fixed result code and an integer count. **The browser posts an id and never a URL or a path.** The permalink is resolved server-side with `get_permalink()`, so there is no parameter for a caller to point somewhere else. That is by construction rather than by validation: a handler that accepted a filesystem path would be a remote deletion endpoint one bug away from working. Deletion itself is [`PageCache\Invalidator`](/guide/page-cache#invalidation) and nothing else, so a button and an automatic purge always agree about which files a page owns. A clear takes the static page cache and only that — the discovery cache, transformed images, application transients and PHP's OPcache have their own lifecycles. ## They keep working with caching off Every control stays available while `enabled` is `false`, and the page says so. A release that had the cache on leaves files behind, and the project switching it off is exactly the one who needs them gone. ## Reaching the same operations elsewhere | Where | How | | ------------- | ------------------------------------------------------------------------------------------ | | WP-CLI | `wp foehn cache:clear [--url=]`, `wp foehn cache:status` | | Your own code | `do_action('foehn/page_cache/purge_post', $postId)`, `do_action('foehn/page_cache/flush')` | See [Page Cache](/guide/page-cache) for what is stored, how invalidation is triggered, and how to install the fast path. --- --- url: /foehn-framework/guide/ai-agents.md --- # AI Agents Føhn ships [Agent Skills](https://agentskills.io/): reference files that teach a coding agent the framework's attributes, conventions and commands. Each package carries its own skill next to its code, so the skill is versioned with the release it describes. | Skill | Package | Source | | ------------------- | ------------------------------- | -------------------------------------------------------- | | `foehn` | `studiometa/foehn` | `packages/foehn/skills/foehn/SKILL.md` | | `foehn-acf` | `studiometa/foehn-acf` | `packages/acf/skills/foehn-acf/SKILL.md` | | `foehn-vite-plugin` | `@studiometa/foehn-vite-plugin` | `packages/vite-plugin/skills/foehn-vite-plugin/SKILL.md` | ## Install The [`skills` CLI](https://github.com/vercel-labs/skills) installs the skills from the GitHub repository for Claude Code, Cursor, Codex, Copilot and more than 40 other agents: ```bash npx skills add studiometa/foehn-framework ``` The command asks which skills to install and for which agents. Pass the choices as options to skip the prompts: ```bash npx skills add studiometa/foehn-framework -s foehn -s foehn-acf -a claude-code -y ``` | Option | Effect | | ------------ | ------------------------------------------ | | `-s ` | Install this skill. Repeat for each skill. | | `-a ` | Install for this agent. | | `-y` | Skip the prompts. | ## Pin to your release Without a ref, the CLI installs the skills from `main`. `main` can describe attributes or options that your project's release does not have yet. Pin the skills to the version of `studiometa/foehn` in your `composer.lock`: ```bash composer show studiometa/foehn | grep versions npx skills add studiometa/foehn-framework#0.6.2 ``` Tags have no `v` prefix: use `#0.6.2`, not `#v0.6.2`. A tree URL works too: ```bash npx skills add https://github.com/studiometa/foehn-framework/tree/0.6.2 ``` The CLI records the ref in `skills-lock.json`. `npx skills update` keeps that ref. To move to a new release, after `composer update studiometa/foehn`, run `add` again with the new tag: ```bash npx skills add studiometa/foehn-framework#0.7.0 ``` Commit `skills-lock.json` with the rest of the project, so every developer and agent reads the same skills. ## Without skills Agents that read documentation directly can use the files the documentation build emits: * [`llms.txt`](https://studiometa.github.io/foehn-framework/llms.txt) — an index of every page; * [`llms-full.txt`](https://studiometa.github.io/foehn-framework/llms-full.txt) — every page in one file. These files follow the current documentation, not a release. --- --- url: /foehn-framework/api.md --- # API Reference This section documents all attributes, interfaces, and core classes in Føhn. ## Attributes Attributes are PHP 8 annotations that enable auto-discovery and registration of WordPress components. ### Hooks | Attribute | Description | | ---------------------------------- | -------------------------------- | | [`#[AsAction]`](./as-action) | Register a WordPress action hook | | [`#[AsFilter]`](./as-filter) | Register a WordPress filter hook | | [`#[AsShortcode]`](./as-shortcode) | Register a shortcode handler | ### Content Types | Attribute | Description | | --------------------------------------- | ------------------------------------------ | | [`#[AsPostType]`](./as-post-type) | Register a custom post type | | [`#[AsPostMeta]`](./as-post-meta) | Register a meta key, with a REST schema | | [`#[AsTaxonomy]`](./as-taxonomy) | Register a custom taxonomy | | [`#[AsMenu]`](./as-menu) | Register a navigation menu location | | [`#[AsTimberModel]`](./as-timber-model) | Map Timber class without type registration | ### Media | Attribute | Description | | ----------------------------------- | ---------------------------- | | [`#[AsImageSize]`](./as-image-size) | Register a custom image size | ### Views | Attribute | Description | | ----------------------------------------------------- | ------------------------------ | | [`#[AsContextProvider]`](./as-context-provider) | Add data to specific templates | | [`#[AsTemplateController]`](./as-template-controller) | Handle template rendering | ### Twig | Attribute | Description | | ------------------------------------------- | ------------------------- | | [`#[AsTwigExtension]`](./as-twig-extension) | Register a Twig extension | ### Blocks | Attribute | Description | | -------------------------------------------- | --------------------------------- | | [`#[AsBlock]`](./as-block) | Register a native Gutenberg block | | [`#[AsAcfBlock]`](./as-acf-block) | Register an ACF block | | [`#[AsAcfFieldGroup]`](./as-acf-field-group) | Register an ACF field group | | [`#[AsBlockPattern]`](./as-block-pattern) | Register a block pattern | | [`#[AsBlockCategory]`](./as-block-category) | Register a block category | | [`#[AsBlockBinding]`](./as-block-binding) | Register a block bindings source | ### ACF | Attribute | Description | | ---------------------------------------------- | ---------------------------- | | [`#[AsAcfOptionsPage]`](./as-acf-options-page) | Register an ACF options page | ### Settings | Attribute | Description | | ----------------------------------------- | ------------------------------------------- | | [`#[AsSettingsPage]`](./as-settings-page) | An admin page on the WordPress Settings API | ### API & CLI | Attribute | Description | | --------------------------------------- | -------------------------------------- | | [`#[AsRestRoute]`](./as-rest-route) | Register a REST API endpoint | | [`#[AsRewriteRule]`](./as-rewrite-rule) | Register a rewrite rule, and answer it | | [`#[AsCliCommand]`](./as-cli-command) | Register a WP-CLI command | ## Interfaces Interfaces define contracts for classes used with specific attributes. | Interface | Used with | | ---------------------------------------------------------------------- | -------------------------------- | | [`BlockInterface`](./block-interface) | `#[AsBlock]` | | [`InteractiveBlockInterface`](./interactive-block-interface) | `#[AsBlock]` with interactivity | | [`AcfBlockInterface`](./acf-block-interface) | `#[AsAcfBlock]` | | [`AcfFieldGroupInterface`](./acf-field-group-interface) | `#[AsAcfFieldGroup]` | | [`AcfOptionsPageInterface`](./acf-options-page-interface) | `#[AsAcfOptionsPage]` (optional) | | [`ContextProviderInterface`](./context-provider-interface) | `#[AsContextProvider]` | | [`TemplateControllerInterface`](./template-controller-interface) | `#[AsTemplateController]` | | [`RewriteHandlerInterface`](./as-rewrite-rule#rewritehandlerinterface) | `#[AsRewriteRule]` (optional) | | [`BlockPatternInterface`](./block-pattern-interface) | `#[AsBlockPattern]` (optional) | ## Configuration | Config Class | Config File | Description | | --------------------------------- | ----------------------- | ------------------------ | | [`FoehnConfig`](./foehn-config) | `app/foehn.config.php` | Core bootstrap settings | | [`TimberConfig`](./timber-config) | `app/timber.config.php` | Template directories | | [`AcfConfig`](./acf-config) | `app/acf.config.php` | ACF field transformation | | [`RestConfig`](./rest-config) | `app/rest.config.php` | REST API permissions | ## Discovery | Class | Description | | ------------------------------------------------ | -------------------------------- | | [`DiscoveryRunner`](./discovery-runner) | Orchestrates discovery lifecycle | | [`#[AsDiscovery]`](./as-discovery) | Declares a discovery's phase | | [`ViewEngineInterface`](./view-engine-interface) | View rendering abstraction | ## Core | Class | Description | | --------------------------------------- | ------------------------ | | [`Kernel`](./kernel) | Main bootstrap class | | [`Helpers`](./helpers) | Global helper functions | | [`CacheInterface`](./cache-interface) | Injectable cache service | | [`WebpackManifest`](./webpack-manifest) | Asset manifest helper | ## DTOs & Traits | Class / Trait | Description | | ---------------------------------------- | ------------------------------------ | | [`Arrayable`](./arrayable) | Interface for DTO → array conversion | | [`HasToArray`](./has-to-array) | Reflection-based `toArray()` trait | | [`LinkData`](./data-dtos#linkdata) | DTO for link/button fields | | [`ImageData`](./data-dtos#imagedata) | DTO for image/attachment fields | | [`SpacingData`](./data-dtos#spacingdata) | DTO for spacing fields | ## Query | Class | Description | | ------------------------------------------ | ----------------------------- | | [`PostQueryBuilder`](./post-query-builder) | Fluent post query builder | | [`QueriesPostType`](./queries-post-type) | Trait for model query methods | --- --- url: /foehn-framework/api/arrayable.md --- # Arrayable Interface for objects that can be converted to an associative array. Used by DTOs returned from `compose()` methods. ## Signature ```php */ public function toArray(): array; } ``` ## Usage Implement this interface on DTOs used as block or pattern context: ```php use Studiometa\Foehn\Concerns\HasToArray; use Studiometa\Foehn\Contracts\Arrayable; final readonly class CardContext implements Arrayable { use HasToArray; public function __construct( public string $title, public string $excerpt, public ?ImageData $image = null, ) {} } ``` The `HasToArray` trait provides a reflection-based `toArray()` that converts public properties to snake\_case keys and recursively flattens nested `Arrayable` objects. ## Related * [Guide: Arrayable DTOs](/guide/arrayable-dtos) * [HasToArray](./has-to-array) --- --- url: /foehn-framework/guide/arrayable-dtos.md --- # Arrayable DTOs Føhn supports typed DTOs (Data Transfer Objects) as an alternative to plain arrays for block and pattern `compose()` methods. DTOs provide autocompletion, type safety, and clear contracts for your template context. ## Overview Instead of returning a plain `array` from `compose()`, you can return an `Arrayable` object. Føhn automatically flattens it to a snake\_case array before reaching `render()` and Twig templates. **Before (plain array):** ```php public function compose(array $block, array $fields): array { return [ 'title' => $fields['title'] ?? '', 'background_image' => ImageData::fromAttachmentId($fields['background'] ?? null), 'cta_link' => LinkData::fromAcf($fields['cta'] ?? null), ]; } ``` **After (typed DTO):** ```php public function compose(array $block, array $fields): HeroContext { return new HeroContext( title: $fields['title'] ?? '', backgroundImage: ImageData::fromAttachmentId($fields['background'] ?? null), ctaLink: LinkData::fromAcf($fields['cta'] ?? null), ); } ``` Both approaches produce the same template context (`title`, `background_image`, `cta_link`). ## Creating a DTO Implement `Arrayable` and use the `HasToArray` trait: ```php toArray(); // [ // 'title' => 'Welcome', // 'background_image' => ['id' => 1, 'src' => '/img.jpg', 'alt' => '', 'width' => null, 'height' => null], // 'cta_link' => ['url' => '/about', 'title' => 'Learn more', 'target' => ''], // 'height' => 'medium', // ] ``` ### Customizing Key Mapping Override `propertyToKey()` to change the mapping strategy: ```php final readonly class MyContext implements Arrayable { use HasToArray; // Keep camelCase keys instead of snake_case protected function propertyToKey(string $name): string { return $name; } } ``` ## Using in Blocks All `compose()` methods on `AcfBlockInterface`, `BlockInterface`, and `BlockPatternInterface` accept either `array` or `Arrayable` as return type. ### ACF Block ```php use Studiometa\Foehn\Attributes\AsAcfBlock; use Studiometa\Foehn\Contracts\AcfBlockInterface; use Studiometa\Foehn\Contracts\ViewEngineInterface; use StoutLogic\AcfBuilder\FieldsBuilder; #[AsAcfBlock(name: 'hero', title: 'Hero Banner')] final readonly class HeroBlock implements AcfBlockInterface { public function __construct( private ViewEngineInterface $view, ) {} public static function fields(): FieldsBuilder { return (new FieldsBuilder('hero')) ->addText('title') ->addImage('background') ->addLink('cta'); } public function compose(array $block, array $fields): HeroContext { return new HeroContext( title: $fields['title'] ?? '', backgroundImage: ImageData::fromAttachmentId($fields['background'] ?? null), ctaLink: LinkData::fromAcf($fields['cta'] ?? null), ); } public function render(array $context, bool $isPreview = false): string { return $this->view->render('blocks/hero', $context); } } ``` ### Block Pattern ```php use Studiometa\Foehn\Attributes\AsBlockPattern; use Studiometa\Foehn\Contracts\BlockPatternInterface; #[AsBlockPattern(name: 'theme/featured', title: 'Featured Section')] final class FeaturedPattern implements BlockPatternInterface { public function context(): FeaturedContext { return new FeaturedContext( posts: \Timber\Timber::get_posts(['posts_per_page' => 3]), ); } } ``` ## Built-in DTOs Føhn provides DTOs for common ACF field patterns: ### LinkData Matches ACF link fields (return\_format: array). ```php use Studiometa\Foehn\Data\LinkData; // From ACF link field $link = LinkData::fromAcf($fields['cta']); // → LinkData { url: '...', title: '...', target: '' } // Manual construction $link = new LinkData(url: '/about', title: 'About Us', target: '_blank'); // Access properties $link->url; // string $link->title; // string $link->target; // string // Convert to array $link->toArray(); // ['url' => '/about', 'title' => 'About Us', 'target' => '_blank'] ``` Returns `null` if the ACF field is empty or null. ### ImageData Matches ACF image fields (return\_format: id). ```php use Studiometa\Foehn\Data\ImageData; // From WordPress attachment ID $image = ImageData::fromAttachmentId($fields['background'], 'large'); // → ImageData { id: 42, src: 'https://...', alt: '...', width: 1920, height: 1080 } // Manual construction $image = new ImageData(id: 42, src: '/img.jpg', alt: 'Photo', width: 800, height: 600); // Access properties $image->id; // int $image->src; // string $image->alt; // string $image->width; // ?int $image->height; // ?int ``` Returns `null` if the ID is invalid or the attachment doesn't exist. ### SpacingData Matches fields produced by `SpacingBuilder`. ```php use Studiometa\Foehn\Data\SpacingData; // From the group that holds the fragment: addGroup('spacing')->addFields(new SpacingBuilder()) $spacing = SpacingData::fromAcf($fields['spacing'] ?? null); // → SpacingData { top: 'large', bottom: 'medium' } // Manual construction $spacing = new SpacingData(top: 'large', bottom: 'small'); // Access properties $spacing->top; // string (default: 'medium') $spacing->bottom; // string (default: 'medium') ``` ## In Twig Templates DTO properties are available as snake\_case keys: ```twig {% verbatim %}{# blocks/hero.twig #}
    {% if background_image %} {{ background_image.alt }} {% endif %}

    {{ title }}

    {% if cta_link %} {{ cta_link.title }} {% endif %}
    {% endverbatim %} ``` ## Related * [ACF Blocks](/guide/acf-blocks) — Using DTOs in ACF blocks * [Block Patterns](/guide/block-patterns) — Using DTOs in block patterns * [Field Fragments](/guide/field-fragments) — ACF field builder helpers --- --- url: /foehn-framework/guide/assets.md --- # Assets Føhn provides two helpers for enqueuing scripts and styles, one per build tool. | Build tool | Helper | Reads | | ---------------------------------------------------------------------------- | ----------------- | --------------------------- | | [Vite](https://vite.dev), through `@studiometa/foehn-vite-plugin` | `ViteManifest` | `dist/.vite/manifest.json` | | [`@studiometa/webpack-config`](https://github.com/studiometa/webpack-config) | `WebpackManifest` | `dist/assets-manifest.json` | They are not interchangeable: the two tools emit different formats. Reach for `ViteManifest` on a new project — it is what the starter and the demo use. ## Vite ```php enqueue('theme/assets/css/app.css', handle: 'theme-styles') ->enqueue('theme/assets/js/app.js', handle: 'theme-app', inFooter: true); } } ``` Entry names are the paths given to the plugin's `input` in `vite.config.js`, because those are the keys Vite writes into the manifest. They are relative to the Vite project root, which is usually the package rather than the theme — so `theme/assets/js/app.js`, not `assets/js/app.js`. ### What it handles for you **The dev server.** While `npm run dev` runs, the plugin writes a `hot` file into the build directory (`theme/dist/hot`), holding the server's URL. `ViteManifest` then loads the Vite client and the entries from that server instead of from the build, so hot module replacement works and a stale `dist/` cannot shadow your edits. Nothing in the theme has to branch on it. In a DDEV project, the dev server URL also shows the site: Vite answers its own routes and every file it holds, and proxies the other requests to `https://.ddev.site`. **The CSS a script imported.** A Vite JavaScript chunk carries the stylesheets it imported in a `css` array, separate from its own `file`. Enqueue the script and miss that array and the page loads with no styles and no error anywhere — so `enqueue()` always registers both. **The module type.** Vite emits ES modules, and a classic `` | | `wp_kses_post()` | HTML content (allows safe tags) | `
    ` | | `wp_kses()` | HTML with custom allowed tags | `echo wp_kses($html, $allowed_tags);` | ### When to Use Each Function #### `esc_html()` — Plain Text Use for any text that should not contain HTML: ```php // ✅ Good: Escaped output echo '

    ' . esc_html($title) . '

    '; // ❌ Bad: Unescaped user input echo '

    ' . $title . '

    '; ``` #### `esc_attr()` — HTML Attributes Use for values inside HTML attributes: ```php // ✅ Good: Escaped attribute echo ''; echo '
    '; echo '
    '; // ❌ Bad: Unescaped attribute (XSS via " onclick="alert(1)) echo ''; ``` #### `esc_url()` — URLs Use for any URL output: ```php // ✅ Good: Escaped URL echo 'Click'; echo ''; // ❌ Bad: Allows javascript: protocol XSS echo 'Click'; ``` #### `wp_kses_post()` — Rich HTML Content Use when you need to allow safe HTML (like post content): ```php // ✅ Good: Allows safe HTML, strips dangerous tags echo '
    ' . wp_kses_post($content) . '
    '; // ❌ Bad: Allows all HTML including