PHPackages                             parisek/timber-kit - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. parisek/timber-kit

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

parisek/timber-kit
==================

WordPress/Timber starter kit — StarterBase, Helpers, Resizer

v1.7.5(3w ago)079↓30%[4 issues](https://github.com/parisek/timber-kit/issues)GPL-3.0-or-laterPHPPHP ^8.3CI passing

Since Mar 21Pushed 1w agoCompare

[ Source](https://github.com/parisek/timber-kit)[ Packagist](https://packagist.org/packages/parisek/timber-kit)[ RSS](/packages/parisek-timber-kit/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (10)Dependencies (68)Versions (33)Used By (0)

timber-kit
==========

[](#timber-kit)

WordPress/Timber starter kit — configurable base class, ACF helpers, image resizer, dev media proxy, WPForms config bridge, ACF block renderer, WPML Copy-field override.

Installation
------------

[](#installation)

```
composer require parisek/timber-kit
```

What's Included
---------------

[](#whats-included)

### StarterBase

[](#starterbase)

Extends `Timber\Site` with 25 configurable properties. Handles theme setup, Twig extensions, security hardening, Gutenberg blocks, media processing, and admin cleanup — all opt-in via boolean flags.

### Helpers

[](#helpers)

Static methods for formatting ACF data into clean arrays for Twig templates:

- `formatImage()`, `formatFile()`, `formatVideo()` — media formatting
- `formatFields()`, `fieldFormatter()` — ACF field processing
- `formatLink()` — link/button formatting
- `remapWpmlReference( $value, array $field, string $target_lang )` — remaps an ACF reference field's id(s) to a target WPML language via `wpml_object_id`, with the element type resolved per ACF field type (`image`/`file`/`gallery` → attachment, `post_object`/`relationship`/`page_link` → post, `taxonomy` → term; non-reference and non-numeric values pass through). Shared formatting-layer primitive that `WpmlBlockOverride` delegates to, reusable by any field formatter
- `formatMenu()` — navigation menus
- `formatTerms()` — taxonomy terms
- `formatLanguageSwitcher()` — WPML language switcher
- `resizeImage()` — responsive image variants
- `pagination()` — pagination formatting
- `readTime()` — estimated reading time in minutes (Unicode-aware word counting, image budget, WPML-aware per-language WPM)
- `getLanguage()` — normalized (lowercased, trimmed) language code for a post or the current request, with WPML per-post / site-wide / locale fallbacks. WPML region/script subtags are preserved (e.g. `pt-br`, `zh-hans`); only the locale fallback is strictly 2 letters
- `formatImageFrom( ?array $raw ): ?array` — pure-core formatter extracted from `formatImage()`'s associative-array branch. No WordPress dependencies, safe for unit / property tests; missing keys resolve to `null` silently, `id` / `width` / `height` are cast to `int|null`, and the WordPress SVG-1px workaround is applied uniformly

### Resizer

[](#resizer)

Image resizing via [Spatie/Image](https://github.com/spatie/image). AVIF output, responsive variants with breakpoints, crop positions, and cache management. Exposed as a single polymorphic Twig filter, `|resizer`, that detects its argument shape and routes to one of two underlying methods.

#### Tuples mode (positional, variadic)

[](#tuples-mode-positional-variadic)

Caller passes the variant tuples directly, in order. Each tuple is `[width, height, media-min-width, image_style, quality?]` — same shape `Resizer::resizer()` consumes:

```
{{ component_picture({
    image: item.image|resizer(
        ['960', '720', '1280', 'crop'],
        ['480', '360', '',     'crop'],
    ),
}) }}
```

#### Orientation-aware mode (single map arg)

[](#orientation-aware-mode-single-map-arg)

When the single argument is an associative array carrying at least one of `landscape` / `portrait` / `square` keys, the filter classifies the source image's aspect (±10 % tolerance band around 1:1, overridable via the `timber_kit_resizer_aspect_tolerance` WP filter) and dispatches the matching tuple set to the standard resize pipeline:

```
{{ component_picture({
    image: item.image|resizer({
        landscape: [['960', '720', '1280', 'crop'], ['480', '360', '', 'crop']],
        portrait:  [['720', '960', '1280', 'crop'], ['360', '480', '', 'crop']],
        square:    [['800', '800', '1280', 'crop'], ['400', '400', '', 'crop']],
    }),
}) }}
```

Lets templates drop the inline `image.width >= image.height` branch.

**Fallbacks.** Missing-metadata / non-numeric / zero-dimension sources classify as `landscape` (preserves the historical wide-crop default for legacy assets). When the matched bucket has no tuples (empty array or absent key), the helper falls through to the `landscape` bucket; if that's also empty / absent, the source passes through unchanged rather than crashing with an empty ``.

**Detection (how the two shapes coexist).** The dispatch lives in `Resizer::isOrientationMap()`: a single arg that's an associative array with at least one recognised key flips into orientation mode. Tuples have integer keys (width / height / media / image\_style / quality), so the two shapes can't realistically collide. PHP callers wanting the bucket without the resize step can call `Resizer::classifyAspect()` directly.

### DevMediaProxy

[](#devmediaproxy)

Development-only media proxy for projects that do not keep `wp-content/uploads` synchronized locally. When `TIMBERKIT_MEDIA_ORIGIN` is configured, missing local media URLs are rewritten to the upstream origin for common WordPress media surfaces and Media Library payloads.

It also integrates with `Resizer` through the `timber_kit_resizer_missing_source_variants` filter, so missing local source images can fall back to already-generated remote variants before returning the original image URL.

### WPFormsConfigBridge

[](#wpformsconfigbridge)

Bridges `wp-config.php` constants to entries of the `wpforms_settings` option, so per-environment values such as Cloudflare Turnstile test keys can be stored in environment config rather than the WordPress database.

A setting key `turnstile-site-key` is overridden by a constant `WPFORMS_TURNSTILE_SITE_KEY` (hyphens become underscores, the whole name uppercased). The bridge is activated automatically by `StarterBase` when WPForms is loaded.

### BlockRenderer

[](#blockrenderer)

Render callback for ACF Gutenberg blocks defined via `block.json`. Migrated from per-theme `functions.php` so projects derived from `portadesign/wordpress-base` carry one versioned source of truth instead of duplicating ~140 lines per theme.

Wire as `block.json` `renderCallback`:

```
{
    "acf": {
        "renderCallback": "Parisek\\TimberKit\\BlockRenderer::render"
    }
}
```

Or call from a wrapper in your theme's `functions.php` for backwards-compatible block.json files:

```
function timber_block_render_callback( ...$args ): void {
    \Parisek\TimberKit\BlockRenderer::render( ...$args );
}
```

What it does:

- Resolves ACF block.json schema to a Twig template path
- Hydrates content via `Helpers::formatFields()`
- Two-tier cache: in-request memo for editor previews + external object cache (Redis with `flush_group`) for the frontend, gated by `has_filter()` (dynamic blocks skip frontend cache)
- Detects asset-enqueueing side effects (CF7, WPForms, …) and skips cache writes for those blocks so forms keep working
- Skips frontend cache writes for the editor-only empty-block warning so anonymous visitors don't see warnings meant for editors
- Renders a `.block-editor-warning` template for empty blocks when a logged-in user views them — uses Gutenberg's native classes so the editor styles it without shipping CSS
- Wraps inserter-library previews in a 16:9 aspect-ratio box for consistent thumbnails
- Skips the `block__content` filter during inserter-library previews so example data isn't enriched with derived values that would distort thumbnails

The class is `final` with three public static methods: `render()`, `isInserterPreview()`, `flushPostBlockCache()`.

#### Filters

[](#filters)

**Package-level filters** (stable across versions, prefixed `timber_kit/`):

FilterArgsPurpose`timber_kit/block_renderer/cache_key``(string $key, array $cache_data, string $block_name)`Override the cache key composition (e.g. add user role / segment to the variation vectors). Default: `'acf_block_' . md5(wp_json_encode($cache_data))` with `$cache_data` = `[name, data, anchor, className, post_id, lang, paged]`.`timber_kit/block_renderer/use_cache``(bool $enabled, string $block_name, array $attributes)`Override the cache-enabled decision per block. Default: `true` when the block has no registered `block__content` filter and the site uses an external object cache with `flush_group` support.`timber_kit/block_renderer/content_data``(?array $content\_data, intstring $post\_id, bool $is\_preview, array $attributes)``timber_kit/block_renderer/context``(array $context, string $block_name, bool $is_preview)`Last-chance Twig context modification before `Timber::compile()` runs.`timber_kit/block_renderer/empty_alert_html``(string $html, string $block_name, array $attributes)`Replace the empty-block warning HTML entirely. Themes can return their own Twig render here (see migration example below).**Per-block legacy filters** (preserved from the original `timber_block_render_callback` for backwards compatibility — `` is the block name with `acf/` stripped and dashes converted to underscores, e.g. `acf/article-featured` → `article_featured`):

FilterArgsPurpose`block__content``(array $content_data)`Per-block content transform (legacy hook preserved for backwards compatibility). Skipped during inserter-library previews so example data isn't enriched with derived values that would distort thumbnails.`block__template``(string $template_path, array $content_data)`Per-block template path override (legacy hook). Runs in all modes including inserter previews. Default path: `@component//.twig`.#### Twig template

[](#twig-template)

`empty-alert.twig` is shipped under the `@timber-kit/` Twig namespace, registered automatically by `StarterBase` at priority 20 (so theme paths under the same namespace take precedence). It uses Gutenberg's `.block-editor-warning` classes for native editor styling and exposes a stable `.timber-kit-block-empty` class + `data-block` attribute for theme overrides.

#### Cache invalidation

[](#cache-invalidation)

`BlockRenderer::flushPostBlockCache($post_id)` is the handler `StarterBase` wires to `acf/save_post` at priority 20. When ACF saves a post, the cache group `acf_block_{$post_id}` is flushed — invalidating exactly the cached blocks tied to that post without touching others. The handler guards against non-numeric ids (ACF options-page strings, opaque `block_*` ids) and against environments without `wp_cache_supports('flush_group')`.

### WpmlBlockOverride

[](#wpmlblockoverride)

Runtime override of Copy field values in ACF Gutenberg blocks for WPML-multilingual sites. Hooks `render_block_data` at priority 20 (after WPML's own handlers) and, for ACF blocks rendered in a non-default language, overwrites `attrs.data.` for fields marked `wpml_cf_preferences = 1` (Copy) with the source-language post's value. Attachment IDs (image / file / gallery) are remapped to per-language duplicates via `wpml_object_id`.

Solves the long-standing WPML problem where changing a Copy field (typically an image) in the source language never propagates to translated `post_content` without a manual ATE re-job. ACF configuration becomes the single source of truth for Copy fields — no DB writes, no admin UI, no drift.

Enable it with the `$wpml_block_override` flag on your `Base extends StarterBase` — opt-in (default off) because it changes rendered output. Set it before `parent::__construct()`:

```
class Base extends StarterBase {
    public function __construct() {
        $this->wpml_block_override = true;
        parent::__construct();
    }
}
```

StarterBase then hooks `WpmlBlockOverride::register()` on `init` when the flag is on. `register()` self-guards on WPML + ACF Pro, so it no-ops where they're absent. If you don't extend `StarterBase`, call it yourself:

```
add_action( 'init', static function (): void {
    if ( class_exists( \Parisek\TimberKit\WpmlBlockOverride::class ) ) {
        \Parisek\TimberKit\WpmlBlockOverride::register();
    }
} );
```

Requirements (verified at `register()`):

- WPML active (`ICL_SITEPRESS_VERSION` defined)
- ACF Pro active (`acf_get_field_groups` available)

#### What it does

[](#what-it-does)

- Bypasses non-ACF blocks, admin context, REST requests, and the default language
- Walks ACF field definitions recursively to find every leaf marked `wpml_cf_preferences = 1` — top-level, plus nested inside repeater / group containers at arbitrary depth
- Generates ACF's flattened block-data key pattern for each Copy field (`items_N_image`, `faq_sections_N_items_M_title`, …) and overrides each from source
- Remaps reference ids to their target-language equivalents via the shared `Helpers::remapWpmlReference()` primitive (so this and the field formatters resolve translated entities the same way), so a translated page points at translated entities — not the source-language ones:

    ACF field typeRemapped asNotes`image`, `file`, `gallery`attachment`post_object`, `relationship`, `page_link`postelement type resolved per id via `get_post_type()` (a `page_link` holding a raw URL passes through)`taxonomy`termelement type is the field's `taxonomy``user`, `link`, scalar fields—not remapped (`user`: WPML doesn't translate users; `link`: URL handled by WPML's own link conversion)
- Caches the full block-name → copy-fields index as a single transient with per-request memo
- Skips the persistent transient entirely under `WP_DEBUG` so dev iteration doesn't need manual invalidation
- Emits diagnostic `error_log` lines (`[timber_kit/wpml_block_override] …`) under `WP_DEBUG` for override events and missing source-block matches

#### Filters

[](#filters-1)

FilterArgsPurpose`timber_kit/wpml_block_override/should_override``(bool $default, array $block, string $current_lang, string $default_lang)`Per-block veto. Default `true` after non-ACF / admin / REST / default-language guards have passed.`timber_kit/wpml_block_override/copy_fields``(array $copy_fields, string $block_name)`Extend or trim the Copy-field discovery for a block. `$block_name` is the **short** name (no `acf/` prefix). `$copy_fields` shape: `[ ['field' => array, 'path' => array], … ]`.Note the two filters receive the block name differently: `should_override` gets the full parsed block (`$block['blockName']` is `acf/foo`), while `copy_fields` gets the short name (`foo`).

**`should_override` and duplicate blocks.** The veto runs *before* positional pairing, so it must be deterministic per block **name**, not per **instance**. If a page has 2+ blocks of the same name and you veto only some instances, the surviving ones' ordinals shift and pair with the wrong source block (silently applying a sibling's Copy value). Decide per block *type*, as the examples below do — never per individual occurrence.

#### Disabling / opting out

[](#disabling--opting-out)

**Per project** — the simplest opt-out is to not call `register()` from the theme. To force it off at runtime even where `register()` already ran (e.g. a shared bootstrap), veto every block:

```
add_filter( 'timber_kit/wpml_block_override/should_override', '__return_false' );
```

**Per block** — skip specific block types via `should_override` (full `acf/` name here):

```
add_filter( 'timber_kit/wpml_block_override/should_override', function ( $enabled, $block ) {
    $off = [ 'acf/hero-text', 'acf/booking-form' ];
    return in_array( $block['blockName'] ?? '', $off, true ) ? false : $enabled;
}, 10, 2 );
```

**Per field** — keep the block syncing but drop one field from the Copy set via `copy_fields` (short block name here; the returned list is re-normalized, so re-indexing isn't required):

```
add_filter( 'timber_kit/wpml_block_override/copy_fields', function ( $copy_fields, $block_name ) {
    if ( $block_name !== 'jumbotron-video' ) {
        return $copy_fields;
    }
    return array_values( array_filter(
        $copy_fields,
        fn ( $entry ) => $entry['field']['name'] !== 'background_image'
    ) );
}, 10, 2 );
```

#### Not supported (this iteration)

[](#not-supported-this-iteration)

- `flexible_content` sub-fields — per-layout `sub_fields` require layout-name awareness
- REST API output — `render_block_data` doesn't fire for raw REST responses; out of scope for server-rendered themes

#### Known limitations

[](#known-limitations)

**Stale cache on programmatic field registration.** Cache invalidation hooks (`acf/update_field_group` + `save_post_acf-field-group`) do **not** fire for programmatic field registration via `acf_add_local_field_group()`. Code-only changes to `wpml_cf_preferences` will serve stale cache for up to 24 hours on production. Under `WP_DEBUG` the persistent transient is bypassed entirely so dev iteration is unaffected. Production workaround: `wp transient delete timber_kit_wpml_copy_fields_index` in the deploy script, or include a theme-version constant in the cache key.

**Reordered duplicate blocks / rows.** Both same-named blocks and a repeater's rows within a matched block are paired by position, relying on source and translation sharing the same order and count. Add/remove is guarded at **both** levels — if the counts of a block name differ, that name is skipped; if a repeater's row count differs between source and translation, that nested field is skipped (no-op). The one unguarded case is an *equal-count manual swap*: a translation edited independently (not through ATE, which rebuilds from the source and preserves order) where two same-named blocks — or two rows of the same repeater — are reordered without changing the count. Positional matching would then apply one instance's Copy value to the other. There is no stable per-instance id in `post_content` to detect this, and the blast radius is bounded — a Copy value from a sibling of the *same type*, read-time only (no DB writes). If you reorder duplicate blocks or rows in a translation independently, re-run it through the WPML translation editor to restore source order.

Usage
-----

[](#usage)

Create a `Base` class in your theme that extends `StarterBase`:

```
