> wp-sage

Conventions for WordPress Bedrock + Sage + Acorn + Blade + Tailwind CSS 4 + Vite projects. Always-active rules.

fetch
$curl "https://skillshub.wtf/alessioarzenton/claude-code-wp-toolkit/wp-sage?format=md"
SKILL.mdwp-sage

WP-Sage Stack — Bedrock + Sage + Acorn

Stack

  • WordPress 6.x + Bedrock
  • PHP 8.2+ (recommended 8.4)
  • Theme: Sage + Acorn ^5.0 — Laravel Blade templating
  • CSS: Tailwind CSS 4 (CSS-first config via @theme, prefix {{PREFIX}})
  • Bundler: Vite (NOT Bud) — laravel-vite-plugin + @roots/vite-plugin
  • Custom Fields: ACF Pro + ACF Composer (log1x/acf-composer)
  • Post Types/Taxonomies: Acorn Post Types (roots/acorn-post-types) + Extended CPTs (johnbillion/extended-cpts)

Typical project structure

{{THEME_DIR}}/
├── app/
│   ├── Blocks/              # ACF Composer blocks
│   ├── Fields/              # ACF Composer field groups
│   ├── Options/             # ACF Composer option pages
│   ├── Providers/ThemeServiceProvider.php
│   ├── View/Composers/      # View composers (App, Header, Footer, etc.)
│   ├── setup.php            # Theme setup (features, nav menus)
│   ├── filters.php
│   └── import.php           # Helper functions
├── config/
│   ├── acf.php              # ACF Composer configuration
│   └── post-types.php       # Post types and taxonomies registration
├── resources/
│   ├── views/
│   │   ├── layouts/app.blade.php
│   │   ├── components/      # Composite Blade components
│   │   ├── blocks/          # ACF/Gutenberg block templates
│   │   ├── sections/        # Header, footer
│   │   ├── partials/        # Content partials, cards
│   │   ├── common/          # Reusable utilities
│   │   └── forms/           # Form templates
│   ├── css/
│   │   ├── app.css          # CSS entry point
│   │   ├── common/          # theme.css, custom-properties.css, semantic-color.css, base.css
│   │   └── components/      # Atomic design: atoms/ molecules/ organisms/ design-system/
│   ├── js/
│   ├── images/ and fonts/
└── vite.config.js

Prefix {{PREFIX}} — MANDATORY

TypeFormatExample
Tailwind utilities{{PREFIX}}:{utility}{{PREFIX}}:flex, {{PREFIX}}:p-4, {{PREFIX}}:md:grid
CSS components{{PREFIX}}-{name}.{{PREFIX}}-button, .{{PREFIX}}-card, .{{PREFIX}}-container
Semantic utilities{{PREFIX}}-{cat}-{var}{{PREFIX}}-content-01, {{PREFIX}}-background-primary

CSS import: @import 'tailwindcss' prefix({{PREFIX}});

Approach

  • Utility-first: don't create custom CSS classes when TW4 utilities suffice
  • Use @theme variables — never hardcode colors, spacing, or fonts
  • Mobile-first: base styles = mobile, then {{PREFIX}}:md:, {{PREFIX}}:lg:, {{PREFIX}}:xl:

Naming

TypeConvention
Blade fileskebab-case (page-header-simple.blade.php)
PHP classesPascalCase
Helper functionssnake_case
Blade variablescamelCase

Code Style

  • PHP: PSR-12 + Laravel Pint (pint.json)
  • JS/CSS/HTML: Prettier (120 chars, single quotes, trailing comma ES5)
  • Blade: blade-formatter (.bladeformatterrc)
  • Tailwind: prettier-plugin-tailwindcss for class sorting

Blade & View Composers

  • Blade components are used with @include(), not <x-component>
  • View Composers extend Roots\Acorn\View\Composer
  • Composer method with() returns an array of data for the view
  • Automatic registration via ThemeServiceProvider

Post Types and Taxonomies (roots/acorn-post-types)

Post types and taxonomies are defined in config/post-types.php and automatically registered via roots/acorn-post-types using John Billion's Extended CPTs library.

config/post-types.php structure:

return [
    'post_types' => [
        'cpt_name' => [
            'names' => [
                'singular' => 'Singular',
                'plural' => 'Plural',
                'slug' => 'cpt-name',
            ],
            'labels' => [...],  // Full WP labels
            'menu_icon' => 'dashicons-admin-post',
            'supports' => ['title', 'editor', 'thumbnail', 'custom-fields'],
            'hierarchical' => false,
            'has_archive' => true,
            'show_in_rest' => true,
            'public' => true,
            'publicly_queryable' => true,
            'exclude_from_search' => false,
        ],
    ],
    'taxonomies' => [
        'tax_name' => [
            'post_types' => ['post', 'page', 'cpt_name'],
            'names' => [
                'singular' => 'Singular',
                'plural' => 'Plural',
            ],
            'labels' => [...],
            'hierarchical' => true,
            'show_in_rest' => true,
        ],
    ],
];

Loading config in ThemeServiceProvider:

public function register()
{
    parent::register();

    $configPath = get_theme_file_path('config/post-types.php');
    if (is_file($configPath)) {
        $this->app->make('config')->set('post-types', require $configPath);
    }
}

Best practices:

  • Always use Extended CPTs features (admin cols, filters, etc.)
  • hierarchical: true for page-like post types
  • show_in_rest: true for Gutenberg
  • exclude_from_search: true only for internal CPTs
  • Slugs always in English, labels localized

ACF Composer — Field Groups

ACF field groups are defined as PHP classes in app/Fields/ extending Log1x\AcfComposer\Field.

Example app/Fields/ExampleFields.php:

<?php

namespace App\Fields;

use Log1x\AcfComposer\Builder;
use Log1x\AcfComposer\Field;

class ExampleFields extends Field
{
    public function fields(): array
    {
        $fields = Builder::make('example_fields');

        $fields
            ->setLocation()
                ->where('post_type', 'post');

        $fields
            ->addTab(__('General', '{{TEXT_DOMAIN}}'), [
                'placement' => 'top',
            ])
            ->addText('title', [
                'label' => __('Title', '{{TEXT_DOMAIN}}'),
                'instructions' => __('Enter a title', '{{TEXT_DOMAIN}}'),
                'required' => 1,
            ])
            ->addTextarea('description', [
                'label' => __('Description', '{{TEXT_DOMAIN}}'),
                'maxlength' => 300,
            ])
            ->addImage('image', [
                'label' => __('Image', '{{TEXT_DOMAIN}}'),
                'return_format' => 'array',
                'preview_size' => 'medium',
            ])
            ->addRepeater('items', [
                'label' => __('Items', '{{TEXT_DOMAIN}}'),
                'layout' => 'table',
                'button_label' => __('Add Item', '{{TEXT_DOMAIN}}'),
            ])
                ->addText('name')
                ->addTextarea('description')
                ->endRepeater();

        return $fields->build();
    }
}

Location rules:

$fields
    ->setLocation()
        ->where('post_type', 'post')
        ->or('post_type', 'page')
        ->or('page_template', 'template-custom.blade.php');

Useful commands:

wp acorn acf:make field FieldName      # Generate field group
wp acorn acf:cache                     # Cache fields (prod)
wp acorn acorn ide:helpers             # PHPDoc autocomplete

ACF Composer — Gutenberg Blocks

Blocks are defined in app/Blocks/ extending Log1x\AcfComposer\Block.

Example app/Blocks/ExampleBlock.php:

<?php

namespace App\Blocks;

use Log1x\AcfComposer\Block;
use Log1x\AcfComposer\Builder;

class ExampleBlock extends Block
{
    public $name = 'Example Block';
    public $description = 'Block description';
    public $category = 'theme';  // or 'common', 'formatting', etc.
    public $icon = 'admin-post';  // dashicon
    public $keywords = ['example', 'test'];
    public $post_types = ['page', 'post'];  // Restrict to specific CPTs

    // Supported alignments
    public $supports = [
        'align' => ['wide', 'full'],
        'mode' => false,  // Disable edit/preview toggle
        'jsx' => true,
    ];

    public function fields(): array
    {
        $fields = Builder::make('example_block');

        $fields
            ->addText('title', [
                'label' => __('Title', '{{TEXT_DOMAIN}}'),
            ])
            ->addWysiwyg('content', [
                'label' => __('Content', '{{TEXT_DOMAIN}}'),
            ]);

        return $fields->build();
    }

    // Data passed to the Blade template
    public function with(): array
    {
        return [
            'title' => $this->title,
            'content' => $this->content,
            'classes' => $this->classes,  // Automatic CSS classes
        ];
    }
}

Template resources/views/blocks/example-block/example-block.blade.php:

<div {!! $attributes !!}>
  <h2>{{ $title }}</h2>
  <div>{!! $content !!}</div>
</div>

$attributes helper: automatically generates class, id, data- attributes.

Commands:

wp acorn acf:make block BlockName      # Generate block + template

ACF Composer — Option Pages

Option pages for theme/site settings in app/Options/.

Example app/Options/ThemeSettings.php:

<?php

namespace App\Options;

use Log1x\AcfComposer\Builder;
use Log1x\AcfComposer\Options as Field;

class ThemeSettings extends Field
{
    public $name = 'Theme Settings';
    public $title = 'Settings | Theme';
    public $menu_slug = 'theme-settings';
    public $parent = 'options-general.php';  // Under "Settings"
    public $capability = 'manage_options';
    public $position = 30;
    public $redirect = false;

    public function fields(): array
    {
        $fields = Builder::make('theme_settings');

        $fields
            ->addTab(__('General', '{{TEXT_DOMAIN}}'))
            ->addText('site_phone', [
                'label' => __('Phone', '{{TEXT_DOMAIN}}'),
            ])
            ->addText('site_email', [
                'label' => __('Email', '{{TEXT_DOMAIN}}'),
            ]);

        return $fields->build();
    }
}

Retrieving options:

$phone = get_field('site_phone', 'option');

Commands:

wp acorn acf:make options OptionsName  # Generate option page

Vite Aliases

@scripts → js, @styles → css, @fonts → fonts, @images → images

What NOT to do

  • Don't forget the {{PREFIX}}: prefix on Tailwind utilities — it's mandatory
  • Don't use @apply in CSS — prefer utilities in markup
  • Don't hardcode colors, spacing, fonts — use @theme variables
  • Don't regenerate existing components — check {{COMPONENTS_CATALOG}}
  • Don't use bud.config.js — the bundler is Vite
  • Don't modify vite.config.js without confirmation
  • Don't generate JavaScript unless requested
  • Don't use <a role="button"> or clickable <div> — use native elements
  • Don't remove outline on :focus without a visible alternative
  • Don't omit aria-hidden="true" focusable="false" on decorative SVGs
  • Don't add alt text to decorative images — use alt=""
  • Don't use fixed heading levels in reusable components — make them parametric
  • Don't register post types/taxonomies with register_post_type() — use config/post-types.php
  • Don't create ACF field groups via JSON — use ACF Composer (app/Fields/)
  • Don't register blocks with register_block_type() — use ACF Composer (app/Blocks/)
  • Don't use get_field() directly in templates — pass data via with() in the Block/Composer

Nunjucks → Blade Migration (if applicable)

If the project migrates from a Nunjucks design system:

  • .njk.blade.php in resources/views/components/{atoms|molecules|organisms}/
  • .jsresources/js/{atoms|molecules|organisms}/
  • .css → stay in resources/css/components/ (unchanged)
  • .config.yml → View Composer or @include() parameters

┌ stats

installs/wk0
░░░░░░░░░░
first seenMar 17, 2026
└────────────

┌ repo

alessioarzenton/claude-code-wp-toolkit
by alessioarzenton
└────────────

┌ tags

└────────────