Skip to content

Repository files navigation

threespot/wp-base-config

Shared WordPress configuration and helper functions for Threespot sites.

What this is

Threespot WordPress sites historically copy-pasted the same ~12 PHP files (admin-config.php, asset-config.php, image-helpers.php, etc.) into every new project's Sage theme. Improvements made on one site didn't flow back to the others, and every new build started by porting bug fixes from whichever site had the most recent version.

This repo extracts that shared layer into one private Composer package. A site adds it as a dependency, points it at its compiled asset URLs via a few filters, and replaces hundreds of lines of app/*-config.php boilerplate with ~15 lines of helper calls.

What's in the package

Two kinds of code:

MU-plugin modules (src/MuPlugins/*Config.php) register WordPress hooks and ship as a single mu-plugin. They survive theme swaps, exactly like the legacy app/*-config.php files did. The ten modules cover:

Module Responsibility
AcfConfig ACF options page, custom WYSIWYG toolbars, "Hide Label" field setting
AcornConfig On Pantheon, moves Acorn's cache, compiled views and log to the private files directory. See Acorn storage on Pantheon
AdminConfig Dashboard widgets, admin bar, customizer sections, robots.txt, SVG uploads, ~20 other behaviors
AssetConfig Script defer policy, optional local jQuery, oEmbed cleanup
BlockConfig Block editor polish: custom category, h1 → h2, lazy iframes, editor CSS/JS
ContentTypesConfig Auto-loads site-level CPT/taxonomy definitions from a fixed mu-plugins convention; auto-applies taxonomy query_var filters to archive queries
CriticalConfig Inline critical <script> in <head> — no-js/js swap, iOS / old-Safari UA classes
LoginConfig /wp-login.php logo URL/text and custom stylesheet enqueue
SmtpConfig PHPMailer override (Mailhog on Lando by default)
ThemeConfig add_theme_support calls, archive-title cleanup, excerpt format

Helper functions (src/Helpers/*.php, namespace Threespot\Wp\Helpers) are pure PHP utilities autoloaded via Composer's autoload.files. Templates call them directly. The four files mirror the legacy app/ layout: general.php, image.php, svg.php, taxonomy.php.

A small public API (src/PublicApi/functions.php) exposes global threespot_* helpers — threespot_remove_dashboard_widget(...), threespot_no_defer_script(...), etc. — so per-site overrides stay declarative instead of fiddling with raw filters.

Installing in a WordPress project

Two steps. Assumes a Bedrock-based site (i.e. vendor/autoload.php is loaded by config/application.php).

1. Add the package to the site's composer.json

{
  "require": {
    "threespot/wp-base-config": "^0.2"
  },
  "repositories": [
    { "type": "vcs", "url": "https://github.com/threespot/wp-base-config" }
  ]
}

Note that Composer special-cases the caret operator below 1.0: ^0.2 means >=0.2.0 <0.3.0, so it pins the minor. Sites already on ^0.1 stay there until the constraint is changed by hand — see UPGRADING.md before moving one across a minor.

Then composer update threespot/wp-base-config. The helper functions and public API are now autoloaded on every request — they work immediately.

2. Symlink the mu-plugin loader

WordPress only auto-loads files directly inside web/app/mu-plugins/, not files nested in vendor packages. Symlink the loader from the site root:

ln -s ../../vendor/threespot/wp-base-config/mu-plugins/threespot-wp-base-config.php \
      web/app/mu-plugins/threespot-wp-base-config.php

(Or copy it, if symlinks don't fit the deploy target. The loader is a 25-line file that does nothing except require the package's bootstrap.php.)

After composer install, verify:

ls -la web/app/mu-plugins/threespot-wp-base-config.php
lando wp eval 'echo class_exists("Threespot\\Wp\\MuPlugins\\AdminConfig") ? "OK\n" : "missing\n";'

3. (Optional) Copy any standalone drop-in mu-plugins

Alongside the loader, mu-plugins/ carries self-contained mu-plugins that are not wired into bootstrap.php. Each one is opt-in: copy the file into web/app/mu-plugins/ on sites that need it. Copy rather than symlink — these are meant to be owned and edited per-site, and none of them depend on the rest of the package.

File What it does When to use it
acf-local-json-autosync.php On local (Lando) only, enables ACF Extended's force_sync module so committed acf-json field groups auto-import into the DB (and deleted JSON files remove their DB group) whenever the dashboard, Field Groups list, or a field group edit screen is opened. Never runs on Pantheon. Sites using ACF Extended Pro where stale local field groups keep reverting teammates' committed changes.
site-health-auth-guard.php Re-gates Pantheon's Site Health "compatibility" AJAX handler behind the view_site_health_checks capability. The bundled pantheon-mu-plugin (as of v1.5.7) registers it with no capability check, letting any authenticated user enumerate known-problematic active plugins. No-op off Pantheon, and no-op once upstream fixes it. Pantheon sites, until pantheon-mu-plugin ships a fix — then delete it.
suppress-admin-notices.php Prevents specific third-party plugin admin notices (FileBird review/first-folder nags, Yoast Duplicate Post welcome notice) from rendering by short-circuiting the option each notice gates on via pre_option_* / pre_site_option_* filters. Any site running those plugins; edit the arrays at the top of your copy to suppress additional notices.

These drop-ins are deliberately exempt from the package's module conventions: they fire no threespot/* filters and may use closures, because each site edits its own copy directly instead of overriding behavior through hooks.

Acorn storage on Pantheon

Acorn (the framework under Sage) keeps its cache, compiled Blade views and log in wp-content/cache/acorn by default. On Pantheon that folder is read-only, and Sage fails with:

The /code/web/wp-content/cache/acorn/framework/cache directory must be present and writable.

On every Pantheon environment (not Lando), AcornConfig sets ACORN_STORAGE_PATH to wp-content/uploads/private/acorn and creates the folders on the first request. uploads is Pantheon's writable files directory, and Pantheon refuses web requests to its private folder (Pantheon private paths). A new site needs no symlink and no folders created over SFTP. Locally, Acorn keeps its default path.

To use a different path, define ACORN_STORAGE_PATH in config/application.php. The module leaves it alone.

Existing sites

Older sites commit a web/wp-content/cache symlink to uploads/cache, with a cache folder created over SFTP on each environment. That put Acorn's log at a public URL: /wp-content/uploads/cache/acorn/logs/laravel.log. After updating the package and deploying:

  1. Check each environment uses the new path: terminus wp <site>.<env> -- eval 'echo ACORN_STORAGE_PATH;' should print a path ending in uploads/private/acorn.
  2. Delete the old files/cache/acorn folder on each environment over SFTP. It holds the public copy of the log.
  3. Keep the symlink if another plugin writes to wp-content/cache. If not, you can delete it, along with the !web/wp-content/uploads/cache/acorn/... line in .gitignore and the mkdir -p web/wp-content/uploads/cache/acorn/... line in .lando.yml.

Using it in a theme

Once installed, a theme's functions.php typically does three things:

Wire up the theme's compiled asset URLs

The package never calls Vite::asset() directly — it can't, because it has to work for non-Sage themes too. Instead it fires filters at every enqueue point and the theme supplies the URL:

use Illuminate\Support\Facades\Vite;

add_filter('threespot/login/css_url',          fn() => Vite::asset('resources/styles/login.scss'));
add_filter('threespot/admin/all_css_url',      fn() => Vite::asset('resources/styles/admin-all.scss'));
add_filter('threespot/admin/fields_css_url',   fn() => Vite::asset('resources/styles/admin-fields.scss'));
add_filter('threespot/blocks/editor_css_urls', fn() => [
    Vite::asset('resources/styles/gutenberg.scss'),
    Vite::asset('resources/styles/main.scss'),
]);
add_filter('threespot/blocks/editor_js_urls',  fn() => [
    Vite::asset('resources/scripts/gutenberg.js'),
]);

A non-Sage theme would substitute get_template_directory_uri() . '/path'.

Inline the critical <script> in <head>

CriticalConfig ships a small inline script (no-js → js, iOS / old-Safari UA classes). The theme decides where it gets inlined by firing an action:

{{-- in head.blade.php, before wp_head() --}}
@php(do_action('threespot/critical/inline_script'))

Plain PHP equivalent:

<?php do_action('threespot/critical/inline_script'); ?>

The package strips comments and blank lines at render time and caches the result for the request. Output is bigger than a real JS minifier would produce (no whitespace collapsing inside statements) — see src/MuPlugins/CriticalConfig.php for the tradeoff.

Supply the roots/acorn-prettify config

Threespot\Wp\Sage\prettify_config() returns Threespot's default config for the roots/acorn-prettify package (HTML clean-up, nice-search, relative URLs). Acorn only auto-loads config from the theme's config/ directory, so keep a thin config/prettify.php that pulls the defaults in and layers per-site overrides on top:

<?php
return array_replace_recursive(
    Threespot\Wp\Sage\prettify_config(),
    [
        // Per-site overrides, e.g.:
        // 'clean-up' => ['disable-gutenberg-block-css' => true],
    ]
);

array_replace_recursive merges the numerically-indexed relative-urls.hooks list by index, so to change that list assign it wholesale in the override array rather than relying on the recursive merge.

Tweak defaults via the public API

The defaults reflect what the legacy theme files did. Site-specific deltas go through the threespot_* helpers:

// The Yoast dashboard widget is removed by default — put it back on this site.
threespot_keep_dashboard_widget('wpseo-dashboard-overview');

// Add a custom admin-bar node to the removal list.
threespot_remove_admin_bar_node('my-plugin-node');

// jQuery deferral is blocked by default; this site has a plugin that also breaks under defer.
threespot_no_defer_script('my-plugin-js');

// This site does NOT want the customizer "colors" section removed.
threespot_keep_customizer_section('colors');

Each helper is variadic — threespot_remove_admin_bar_node('a', 'b', 'c').

Override single-value settings via raw filters

For things that aren't subtractive lists, just add_filter directly:

add_filter('threespot/blocks/vimeo_color',         fn() => '0066ff');
add_filter('threespot/acf/options_page_title',     fn() => 'Site Settings');
add_filter('threespot/admin/is_internal_user', function ($is_internal, $user) {
    return str_contains($user->user_email, '@threespot.com');
}, 10, 2);

Comments

The package's stance is comments off site-wide, re-enabled per post type. AdminConfig::disableCommentSupport() strips comments and trackbacks support from every post type returned by get_custom_post_types() (which includes post and page), the Comments menu page and admin-bar node are removed, and edit-comments.php redirects to the dashboard.

⚠️ This writes to the database. Removing comments support changes what WordPress saves, not just what the admin shows. get_default_comment_status() returns 'closed' for any post type without comment support — ignoring your default_comment_status option — and that value is stored in wp_posts.comment_status on every post as it is created.

The value persists. Re-enabling comments later does not reopen posts that were created while comments were off. Those rows need a database repair.

To re-enable comments for one post type:

threespot_enable_comments('post');

To re-enable comments site-wide — this empties the disable list, stops the edit-comments.php redirect, and restores the Comments menu page and admin-bar node in one call:

threespot_enable_comments();

Both are variadic and both only affect posts created from that point on.

One core quirk to know about: get_default_comment_status() hardcodes 'closed' for the page post type before it checks support, so pages are saved closed on stock WordPress too. threespot_enable_comments('page') restores the comment metabox but does not change what gets written at creation — you also need add_filter('get_default_comment_status', ...) for that.

To reopen content that was already saved closed, repair the rows directly. Check the damage first:

wp post list --post_type=post --comment_status=closed --format=count

Then, once you're satisfied that's the set you want reopened:

wp db query "UPDATE wp_posts SET comment_status = 'open' \
  WHERE post_type = 'post' AND post_status = 'publish' AND comment_status = 'closed';"

Scope the WHERE clause deliberately — it cannot distinguish posts closed by this package from posts an editor closed on purpose. Back up before running it on production, and note that Pantheon's table prefix may not be wp_.

Calling helper functions from templates

Helpers live in the Threespot\Wp\Helpers namespace. Import once and use short names:

use function Threespot\Wp\Helpers\{img_tag, svg, get_primary_term};

echo img_tag($image_id, ['ratio' => 'sixteen_nine', 'class' => 'hero']);
echo svg(['file' => 'icons/search', 'class' => 'icon', 'width' => 18]);

Or fully-qualify inline (handy in Blade):

{!! \Threespot\Wp\Helpers\img_tag($image_id, ['ratio' => 'sixteen_nine']) !!}

Migrating a theme that already uses App\img_tag(...) — drop a thin shim in the theme's app/helpers.php so existing templates keep working:

namespace App;
function img_tag(...$args) { return \Threespot\Wp\Helpers\img_tag(...$args); }
function svg(...$args)     { return \Threespot\Wp\Helpers\svg(...$args); }

Registering custom post types and taxonomies

ContentTypesConfig auto-loads site-level CPT and taxonomy definitions from a fixed convention inside the site's mu-plugins directory:

mu-plugins/
├── custom-post-types/
│   └── post-types/
│       └── <name>.php          # one file per CPT
└── custom-taxonomies/
    └── taxonomies/
        └── <name>.php          # one file per taxonomy

Sites do not need to wire anything up — drop a file in the expected folder and it's loaded on plugins_loaded. Each file registers one CPT or one taxonomy via extended-cpts:

// mu-plugins/custom-taxonomies/taxonomies/news-type.php
add_action('init', function () {
  register_extended_taxonomy('news_type', ['news'], [
    'query_var' => true,        // ← auto-enables ?news_type=… on archives
    'show_in_rest' => true,
    'show_admin_column' => true,
    // ...
  ], [
    'singular' => 'News Type',
    'plural' => 'News Types',
    'slug' => 'news-type',
  ]);
});

Automatic taxonomy query-var filtering

Setting 'query_var' => true is the one and only opt-in for URL filtering. WordPress already registers the public query var as part of register_taxonomy(), and ContentTypesConfig translates it into a tax_query clause on the main search/archive query. Multiple taxonomies combine with AND — visiting /news/?news_type=press-release&region=northeast returns only items tagged with both.

The dispatcher introspects registered taxonomies at query time, so any non-core taxonomy with query_var => true opts in automatically — including taxonomies registered by other plugins. If that ever becomes a problem, add a skip-list inside ContentTypesConfig::applyTaxonomyQueryVars() rather than special-casing per site.

Archive-specific query tweaks

Custom orderby, posts_per_archive_page, ACF meta sorts — anything keyed by post type — belongs in the same file as register_extended_post_type. One file = one concern.

// mu-plugins/custom-post-types/post-types/event.php
add_action('init', function () {
  register_extended_post_type('event', [/* ... */], [/* ... */]);
});

add_action('pre_get_posts', function ($query) {
  if (is_admin() || !$query->is_main_query() || !$query->is_post_type_archive('event')) {
    return;
  }
  $query->set('meta_key', 'start_time');
  $query->set('meta_type', 'DATE');
  $query->set('orderby', ['meta_value' => 'DESC']);
});

Requires johnbillion/extended-cpts (any version). Sites without it will silently skip loading — no fatal errors.

Frontend tooling configs (dist/)

The package ships Threespot's standard ESLint, Stylelint, PostCSS, and postcss-pxtorem configs in dist/. Themes consume them via symlinks so every site picks up rule updates with composer update.

Files:

dist/
├── eslint.config.js       # ESLint 9+ flat config (browser + Gutenberg globals)
├── .stylelintrc.cjs       # Stylelint w/ stylelint-scss
├── postcss.config.cjs     # postcss-pxtorem + postcss-preset-env
├── .pxtorem-config.cjs    # px → rem conversion rules (loaded by postcss.config.cjs)
├── vite-base.js           # Shared Vite config (consumed via mergeConfig — see below)
└── package.json           # marks this directory ESM — see below; not an npm package

Why dist/package.json exists

vite-base.js and eslint.config.js are ESM, and both Node and Vite decide a .js file's module format from the nearest package.json "type" field. Because those two files are loaded through a symlink into vendor/, the consuming theme's package.json is not the nearest one: the search runs from the resolved real path, walks past this package (which has no package.json of its own), lands on the Bedrock project root, finds no "type", and treats both files as CommonJS. Node then reparses them as ESM anyway (a MODULE_TYPELESS_PACKAGE_JSON warning, with a per-load cost), and Vite 8.2+ warns on every consuming build:

(!) Your Vite config uses features that are unsupported by `configLoader: 'native'`,
    which is planned to become the default in a future major version of Vite:
  - ESM syntax in a file loaded as CommonJS (.../dist/vite-base.js:1:1)

That is not only noise — under configLoader: 'native' the config fails to load on any Node without ESM syntax detection. dist/package.json is a one-key file ({"type": "module"}) that declares the directory ESM and settles both.

Note that NODE_OPTIONS=--preserve-symlinks does not avoid this. It changes Node's resolution, but Vite's config loader runs its own resolver, which follows the symlink to vendor/ regardless — which is why the warning appears even in themes whose vite script sets that flag.

Keep the file to its single key: it is not an npm package and must never gain dependencies. Any new file in dist/ is ESM by default — give it a .cjs extension if it needs require, as the Stylelint, PostCSS and pxtorem configs already do.

Symlink them from the theme

From inside the theme directory (e.g. web/wp-content/themes/sage/):

ln -sf ../../../../vendor/threespot/wp-base-config/dist/eslint.config.js   eslint.config.js
ln -sf ../../../../vendor/threespot/wp-base-config/dist/.stylelintrc.cjs   .stylelintrc.cjs
ln -sf ../../../../vendor/threespot/wp-base-config/dist/postcss.config.cjs postcss.config.cjs
ln -sf ../../../../vendor/threespot/wp-base-config/dist/.pxtorem-config.cjs .pxtorem-config.cjs

The four-level ../../../../ walks from the theme dir up to the Bedrock project root, then back down into vendor/. Commit the symlinks; vendor/ is recreated on every composer install, so the link targets are always populated.

Heads-up: symlinked configs and node_modules

ESLint flat config uses ESM import for its dependencies (@eslint/js, globals). By default Node resolves symlinks before walking up to find node_modules — which means a symlinked eslint.config.js would look for plugins under vendor/threespot/wp-base-config/node_modules, not the theme's.

If ESLint or Stylelint can't find their plugins, run them with --preserve-symlinks:

// package.json
"scripts": {
  "lint:js":  "NODE_OPTIONS=--preserve-symlinks eslint \"resources/scripts/**/*.{js,jsx}\"",
  "lint:css": "NODE_OPTIONS=--preserve-symlinks stylelint \"resources/**/*.{css,scss}\""
}

PostCSS and postcss-pxtorem resolve their plugin lists themselves and aren't affected. The sibling require('./.pxtorem-config.cjs') inside postcss.config.cjs also works through the symlink, since both files live in dist/.

Vite: shared base + per-project wrapper

vite.config.js doesn't fit a pure-symlink pattern because each project needs its own laravel-vite-plugin inputs and Lando hostname. The package ships dist/vite-base.js exporting threespotViteBase({plugins, ...options}) — a function that returns the stable Vite config (plugin options, aliases, esbuild, SVG sprite, server defaults). It also accepts optional per-theme options: jqueryPath (host a local jQuery) and staticCopyTargets (extra files to copy verbatim into the build).

The base intentionally has no npm-package imports. Vite's config-loader bundler (Rolldown in Vite 8+) follows the symlink to vendor/ before any of Vite's resolve options apply, so any bare imports inside vite-base.js would emit UNRESOLVED_IMPORT warnings on every build. Instead, the wrapper in each theme imports the plugin packages itself (they resolve through the theme's own node_modules) and passes the constructors in. The base still owns the plugin options.

Symlink the base file from inside the theme dir:

ln -sf ../../../../vendor/threespot/wp-base-config/dist/vite-base.js threespot-vite-base.js

Theme's vite.config.js:

import { createLogger, defineConfig, mergeConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import mkcert from 'vite-plugin-mkcert';
import { wordpressPlugin, wordpressThemeJson } from '@roots/vite-plugin';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import eslint from 'vite-plugin-eslint';
import stylelint from 'vite-plugin-stylelint';
import browserslist from 'browserslist';
import { browserslistToTargets, Features } from 'lightningcss';

import { threespotViteBase } from './threespot-vite-base.js';

const HOST = 'my-site.lndo.site';

export default mergeConfig(
  threespotViteBase({
    createLogger,
    wordpressPlugin,
    wordpressThemeJson,
    viteStaticCopy,
    createSvgIconsPlugin,
    eslint,
    stylelint,
    browserslist,
    browserslistToTargets,
    Features,
    // Optional: host a local jQuery. Path is relative to the theme's resources/
    // dir; the file is copied to public/build/assets/resources/<path>. Pair it
    // with a matching `threespot/assets/jquery_path` filter (see Asset URLs).
    jqueryPath: 'scripts/lib/jquery-4.0.0.min.js',
    // Optional: extra vite-plugin-static-copy targets, appended to the base set.
    // staticCopyTargets: [{ src: 'resources/scripts/lib/widget.min.js', dest: 'assets' }],
  }),
  defineConfig({
    plugins: [
      laravel({
        input: [
          'resources/scripts/main.js',
          'resources/styles/main.scss',
          // ...per project
        ],
        refresh: ['resources/views/**/*.blade.php'],
        url: process.env.APP_URL,
      }),
      mkcert({ hosts: [HOST] }),
    ],
    server: {
      hmr: { host: HOST },
    },
  }),
);

mergeConfig deep-merges, so anything the wrapper sets layers on top of the base — including arrays of plugins (which concatenate). To override a specific plugin's options, pass replacement instances into threespotViteBase() and the base re-instantiates them with its defaults; or override post-merge by spreading the result and tweaking. The plugin imports must stay in the theme: every theme already lists these as devDependencies in package.json (they ship with Sage), so the boilerplate is one block at the top.

Overriding per project

The configs are intentionally minimal — they cover the rules every Threespot site wants. If a single project needs to add or override a rule, replace the symlink with a small wrapper:

// theme/eslint.config.js (real file, not a symlink)
import threespotConfig from '../../../../vendor/threespot/wp-base-config/dist/eslint.config.js';

export default [
  ...threespotConfig,
  {
    files: ['resources/scripts/checkout/**/*.js'],
    rules: { 'no-console': 'error' },
  },
];

Linting React code

The shared config parses JSX everywhere (jsx: true), and ESLint 9+ counts a component used only in JSX as a reference, so no-unused-vars needs no plugin to see it. That covers Gutenberg wp.element code and plain React alike. The config deliberately does not ship eslint-plugin-react: a React-heavy site and a plain block theme want different rule sets, and the plugin's eslint peer range excludes ESLint 10 for a real reason (see the version note in the example below), so shipping it produced a peer-dependency warning in every consuming theme.

A site that wants React best-practice checks (react/jsx-key, react/no-direct-mutation-state, ...) adds the plugin itself with the wrapper pattern above:

yarn add -D eslint-plugin-react
// theme/eslint.config.js (real file, not a symlink)
import threespotConfig from '../../../../vendor/threespot/wp-base-config/dist/eslint.config.js';
import react from 'eslint-plugin-react';

export default [
  ...threespotConfig,
  {
    files: ['**/*.{js,jsx}'],
    ...react.configs.flat.recommended,
    // Pin the version; do not use 'detect'. As of eslint-plugin-react 7.37,
    // detection calls context.getFilename(), which ESLint 10 removed, and
    // every rule that checks the React version then throws.
    settings: { react: { version: '18.3' } },
    rules: {
      ...react.configs.flat.recommended.rules,
      // Gutenberg's wp.element pragma supplies createElement; React 17+ has
      // the automatic runtime. Neither needs React in scope.
      'react/react-in-jsx-scope': 'off',
    },
  },
];

Verified on ESLint 10 with eslint-plugin-react 7.37.5: the recommended set runs and reports react/jsx-key, react/prop-types and react/display-name.

What stays per-site

Some things vary too much to live in the package:

  • IMAGE_SIZES constant — image dimensions are project-specific. Keep app/image-sizes.php in the site.
  • register_nav_menus() — menu slugs differ per project. The theme's setup.php keeps owning this.
  • Theme assets (main.scss, main.js, critical.scss) — the package owns the deferral policy but not the actual enqueue.
  • Project-specific overrides — anything driven by the filters above.

Filter reference

Asset URLs

The package never calls Vite::asset() directly — it fires a filter at the point of enqueue and the site supplies the URL.

Filter Used by Default
threespot/admin/all_css_url AdminConfig::enqueueAdminStyles null (no stylesheet) — loaded on every admin page
threespot/admin/fields_css_url AdminConfig::enqueueAdminStyles null (no stylesheet) — loaded only on hooks in fields_css_hooks
threespot/assets/jquery_path AssetConfig::replaceJqueryIfConfigured null (use WP core jQuery) — resources/-relative path; the URL is derived from the Vite static-copy output (pair with the jqueryPath Vite option)
threespot/blocks/editor_css_urls BlockConfig::addEditorStyles [] (no stylesheets) — array of URLs loaded into the editor canvas in order
threespot/blocks/editor_js_urls BlockConfig::enqueueEditorAssets [] (no JS bundles) — array of URLs output as <script type="module"> tags in order
threespot/login/css_url LoginConfig null (no stylesheet)

Scalar configuration

Filter Default Notes
threespot/acf/options_page_capability 'edit_posts' Required capability
threespot/acf/options_page_slug 'theme-settings' ACF options page slug
threespot/acf/options_page_title 'Theme Settings' ACF options page title
threespot/acf/wysiwyg_toolbars [] Additional custom toolbars merged on top of defaults
threespot/admin/allow_svg_uploads true Adds image/svg+xml to upload_mimes
threespot/admin/fields_css_hooks (dashboard, edit, post hooks) Admin hooks where fields_css_url is enqueued
threespot/admin/is_internal_user false Predicate fn($_, $user) => bool. Receives current WP_User.
threespot/admin/redirect_comments_screen true Redirects edit-comments.php to the dashboard. See Comments.
threespot/admin/screen_options_per_page 50 Default screen-options page size for new users
threespot/admin/tinymce_body_class 'u-richtext' TinyMCE iframe body class
threespot/assets/disable_oembed_discovery true Strips <link rel="alternate" type="application/json+oembed">
threespot/assets/jquery_version null Version passed to wp_register_script('jquery-core')
threespot/assets/module_script_handles [] Script handles to mark type="module"
threespot/blocks/category ['slug'=>'threespotblock', 'title'=>'Custom Blocks', 'icon'=>null] Custom block category
threespot/blocks/cover_default_overlay_class 'has-dark-background-color' Class copied onto the core/cover wrapper when the cover has no overlay color (filter to '' to disable)
threespot/blocks/disabled_heading_levels [1] Heading levels stripped from core/heading
threespot/blocks/pattern_category ['slug'=>'threespotblock', 'label'=>'Custom Patterns'] Custom pattern category
threespot/blocks/vimeo_color 'ff5100' Read by format_video_iframe() helper
threespot/helpers/excluded_post_types ['ifso_triggers'] Post types stripped from get_custom_post_types()
threespot/login/header_text get_option('blogname') Login logo alt text
threespot/login/header_url home_url() Login logo link
threespot/smtp/config Mailhog defaults host, port, auth, username, password
threespot/smtp/should_configure PANTHEON_ENVIRONMENT === 'lando' Whether to override PHPMailer
threespot/theme/excerpt_length 25 Words
threespot/theme/excerpt_more '…' Excerpt suffix

Subtractive lists

These back the threespot_keep_* / threespot_remove_* helper pairs. The defaults match the legacy theme's behavior.

Filter Public API pair Default contents (abridged)
threespot/admin/admin_bar_nodes_removed_frontend — (use raw filter) search, updates, duplicate-post
threespot/admin/admin_bar_nodes_removed_non_internal — (use raw filter) query-monitor
threespot/admin/admin_bar_nodes_removed_production — (use raw filter) pantheon-hud
threespot/admin/admin_bar_nodes_removed threespot_keep_admin_bar_node / threespot_remove_admin_bar_node comments, customize, fwp-cache, gform-forms, searchwp, updates, wp-logo, wpseo-menu
threespot/admin/closed_metaboxes threespot_collapse_metabox / threespot_uncollapse_metabox ame-cpe-content-permissions, wpseo_meta
threespot/admin/customizer_sections_removed threespot_keep_customizer_section / threespot_remove_customizer_section colors, custom_css, static_front_page
threespot/admin/dashboard_widgets_removed threespot_keep_dashboard_widget / threespot_remove_dashboard_widget welcome_panel, dashboard_primary, dashboard_quick_press, dashboard_secondary, wpseo-dashboard-overview
threespot/admin/disable_comments_post_types threespot_enable_comments / threespot_disable_comments get_custom_post_types() — includes post and page. Has a persistent comment_status side effect — see Comments.
threespot/admin/menu_pages_removed threespot_keep_menu_page / threespot_remove_menu_page edit-comments.php
threespot/admin/screen_options_hidden_columns threespot_hide_screen_options_column / threespot_show_screen_options_column Yoast columns (wpseo-focuskw, etc.)
threespot/admin/site_status_tests_removed — (use raw filter) async.background_updates, direct.available_updates_disk_space, direct.theme_version, direct.update_temp_backup_writable
threespot/admin/taxonomies_hidden_from_nav_menus threespot_hide_taxonomy_from_nav_menus / threespot_show_taxonomy_in_nav_menus category
threespot/admin/user_roles_removed threespot_keep_user_role / threespot_remove_user_role wpseo_manager, wpseo_editor
threespot/assets/do_not_defer_scripts threespot_defer_script / threespot_no_defer_script jQuery / WP / Gravity Forms / WooCommerce / WPForms-reCAPTCHA handles
threespot/blocks/disabled_block_bindings_sources threespot_keep_block_bindings_source / threespot_remove_block_bindings_source core/post-meta (core/post-data must stay registered — WP 6.9+ core blocks render through it)
threespot/helpers/excluded_post_types threespot_include_post_type / threespot_exclude_post_type ifso_triggers

keep_* removes an item from the subtractive list (so the underlying thing is NOT stripped). remove_* adds an item to the list. threespot_defer_script / threespot_no_defer_script follow the same shape: the list is "scripts NOT to defer", so no_defer_script adds to it and defer_script removes from it. threespot_enable_comments / threespot_disable_comments likewise: the list is "post types with comments stripped", so disable_comments adds to it and enable_comments removes from it. threespot_enable_comments() with no arguments is a special case — it clears the list and restores the rest of the comments UI.

Helper function reference

All live in the Threespot\Wp\Helpers namespace, autoloaded via Composer.

File Functions
general.php bytes_to_human_size, strip_p_tags, is_external, nowrap, trim_excerpt, clean_wysiwyg_markup, strip_quotes, obfuscate, get_custom_post_types, format_video_iframe
image.php calculate_height, get_registered_image_sizes, blank_gif, get_image_file_type, bis_get_sizes, bis_srcset, buildAttributes, img_tag, get_img_alt, get_img_focal_point, resolve_bis_crop_param, set_img_alt (plus fly_* deprecated aliases)
svg.php svg, append_icon
taxonomy.php get_primary_term

image.php reads a site-defined IMAGE_SIZES constant — kept per-site because image dimensions vary too much project to project. The image-resizing helpers expect the Better Image Sizes plugin to be active; fly_* aliases remain for sites still on the legacy Fly Dynamic Image Resizer plugin.

Running tests

composer install
composer test

The test suite (PHPUnit + Brain Monkey) covers the pure helper functions, the threespot_* public API, and verifies each *Config::register() wires up the right WordPress hooks. ~90 tests, runs in under a second, no database needed.

Out of scope for v0.1:

  • Helpers/svg.php (DOMDocument + file I/O — needs fixtures)
  • Full img_tag / bis_srcset coverage (requires mocking the Better Image Sizes plugin)
  • Integration tests against a real WP install (use a local Lando test site for end-to-end verification)

GitHub Actions runs the suite on PHP 8.1 / 8.2 / 8.3 for every push and PR against main (see .github/workflows/test.yml).

Secret scanning

gitleaks guards against committing secrets (keys, tokens, passwords, private keys). It runs in two places:

  • Locally, as a pre-commit hook on staged changes. Opt in once per machine:

    brew install pre-commit gitleaks
    pre-commit install            # wires .git/hooks/pre-commit
    pre-commit run --all-files    # optional: scan everything already tracked

    Config lives in .pre-commit-config.yaml and .gitleaks.toml. The local hook is opt-in and unenforced — it is a convenience, not the safety net.

  • In CI, as the authoritative backstop. .github/workflows/gitleaks.yml scans the full commit history on every push and PR to main and fails the build on any finding. Pair it with GitHub's Secret scanning + push protection repo setting (free on public repos) for server-side blocking.

False positives go in the [allowlist] block of .gitleaks.toml — keep entries narrow so real secrets are never masked.

Layout

threespot/wp-base-config/
├── composer.json                                # type: library
├── UPGRADING.md                                 # breaking changes to filters / public names / unhookable callbacks
├── bootstrap.php                                # registers all MU-plugin modules
├── mu-plugins/
│   ├── threespot-wp-base-config.php             # WP loader; symlink into web/app/mu-plugins/
│   ├── acf-local-json-autosync.php              # optional drop-in; copy per-site — see "Standalone drop-in mu-plugins"
│   ├── site-health-auth-guard.php               # optional drop-in; copy per-site (Pantheon only)
│   └── suppress-admin-notices.php               # optional drop-in; copy per-site and edit its lists
├── dist/                                        # frontend tooling configs; symlinked from each theme
│   ├── eslint.config.js
│   ├── .stylelintrc.cjs
│   ├── postcss.config.cjs
│   ├── .pxtorem-config.cjs
│   ├── vite-base.js                             # consumed via mergeConfig — see "Vite: shared base + per-project wrapper"
│   └── package.json                             # {"type": "module"} — see "Why dist/package.json exists"
└── src/
    ├── MuPlugins/                               # named-callback hook modules
    │   ├── AcfConfig.php
    │   ├── AcornConfig.php
    │   ├── AdminConfig.php
    │   ├── AssetConfig.php
    │   ├── BlockConfig.php
    │   ├── ContentTypesConfig.php
    │   ├── CriticalConfig.php
    │   ├── LoginConfig.php
    │   ├── SmtpConfig.php
    │   └── ThemeConfig.php
    ├── Helpers/                                 # autoloaded namespaced functions
    │   ├── author.php
    │   ├── general.php
    │   ├── image.php
    │   ├── menu.php
    │   ├── svg.php
    │   └── taxonomy.php
    ├── Sage/                                    # Acorn/Vite-aware adapter helpers
    │   ├── assets.php                           # vite_asset_url, inline_critical_css
    │   ├── blade.php                            # register_blade_directives
    │   └── prettify.php                         # prettify_config (roots/acorn-prettify defaults)
    ├── Assets/                                  # bundled JS imported by themes
    │   ├── block-defaults.jsx
    │   └── gutenberg-defaults.js
    └── PublicApi/
        └── functions.php                        # threespot_keep_* / threespot_remove_* etc.

Each MuPlugins/*Config::register() registers named static-method callbacks so a site can fully disable any individual behavior with remove_action:

remove_action('wp_loaded', ['Threespot\Wp\MuPlugins\AdminConfig', 'disableCommentSupport']);

Prefer the public API where a pair exists, though — for this example, threespot_enable_comments() is the supported route (see Comments). Unhooking is the escape hatch for behaviors that have no helper.

Renames to these callback names are breaking changes for any site that unhooks them. See UPGRADING.md.

About

Threespot’s opinionated WP config

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages