Category: Plugin

  • Building Better Workflows with Claude and WordPress Studio

    Building Better Workflows with Claude and WordPress Studio

    One of the most beneficial aspects of Claude and agentic development is being able to give it a persistent memory of how to interact with the codebase and what the codebase is. This will let you work faster and more efficiently — after all, wouldn’t you work faster if you were given an introduction to a codebase instead of just shown the code and told to ‘figure it out’?

    For my own setup, I love working with WordPress Studio — it’s tightly integrated with Playground behind the scenes, and runs off an SQLite database, which makes it far less intrusive and likely to conflict with other local development environments or servers you may already have running — and trivial to sync between environments!

    .claude/settings.json

    Firstly, you can mitigate some of the interruptions about Claude prompting you for permission to do specific tasks, but granting them explicitly in advance. For example, we can add the following rules:

    settings.json
    JSON
    {
    "permissions": {
    "allow": [
    "WebFetch(domain:SITESUBDOMAIN.wp.local)",
    "WebFetch(domain:wordpress.org)",
    "Bash(sqlite3 wp-content/database/.ht.sqlite:*)",
    "Bash(studio:*)"
    ]
    }
    }

    Which will enable Claude to do things without needing to stop and confirm as frequently. Some of these might not make too much sense yet, but we’ll get to them below!

    CLAUDE.md

    CLAUDE.md is a file giving Claude a rough overview of what’s going on and directives. The sort of thing you want Claude to know and remember, but don’t want to have to repeat yourself on.

    CLAUDE.md
    Markdown
    @AGENTS.md

    There is also a movement to do a more general AGENTS.md file — most of these would cross-apply, and you can in fact include them in AGENTS.md and just include that in CLAUDE.md via

    CLAUDE.md
    Markdown
    ## What This Repository Is
    A WordPress 6.9.4 development environment using SQLite as the database backend, managed by [WordPress Studio](https://developer.wordpress.com/docs/developer-tools/studio/cli/). The active development focus is the `plugin-name` Gutenberg block plugin, which does things.
    **Local URL:** https://SITESUBDOMAIN.wp.local/

    A high-level summary of the installation and how Claude can interact with it. Providing a local URL to be queried lets it check front-end output as needed, instead of constantly asking you for the current state or handling contingent decision paths that aren’t actually relevant.

    CLAUDE.md
    Markdown
    ## Environment Management (WordPress Studio)
    The local server is managed by WordPress Studio. Key CLI commands:
    ```bash
    studio site start # Start the local site
    studio site stop # Stop the local site
    studio site status # Check if the site is running
    studio wp <wp-cli-command> # Run WP-CLI commands in the Studio context (no separate WP-CLI install needed)
    ```
    Full CLI reference: https://developer.wordpress.com/docs/developer-tools/studio/cli/

    Make sure to enable Studio CLI in Studio’s settings! Once that’s up and running, enabling WP-CLI through Studio can open up a world of tooling for Claude to use and run your setup.

    CLAUDE.md
    Markdown
    ## Plugin Development (plugin-name)
    All plugin source lives in `wp-content/plugins/plugin-name/`. This directory has its own git repository.
    @wp-content/plugins/plugin-name/CLAUDE.md

    I tend to work off a repository hosting the plugin itself, rather than the whole install in the repository. This lets the plugin host its own `CLAUDE.md` file that gets included in the root and describes the plugin’s inner workings, without duplicating that here and having multiple locations to keep updated.

    CLAUDE.md
    Markdown
    ## Architecture
    ### Database
    Uses SQLite instead of MySQL. The database file is at `wp-content/database/.ht.sqlite`. The SQLite Database Integration plugin (`wp-content/mu-plugins/sqlite-database-integration/`) provides the driver via the `wp-content/db.php` drop-in.
    The system `sqlite3` binary is available and can be used to query the database directly when needed:
    ```bash
    sqlite3 wp-content/database/.ht.sqlite "SELECT * FROM wp_options WHERE option_name = 'siteurl';"
    ```

    Often it’s easier to have Claude look directly at the database to validate assumptions or check why something’s linking the way it is. Rather than have it rediscover how to each time, let’s just give it a quick summary and directions for how-to!

    A Plugin’s CLAUDE.md

    As we saw above, it’s often useful to include the plugin’s CLAUDE.md in the installation’s — it may only dig two directories down to discover them on its own and miss something in wp-content/plugins/plugin-name/ otherwise.

    wp-content/plugins/plugin-name/CLAUDE.md
    Markdown
    ## Architecture

    This will vary from project to project — honestly, the easiest way to generate it is probably to have Claude generate phpdoc syntax for all your files and functions that don’t have it yet and then ask Claude to document it for you. This may not be perfect, but it’ll generally make a point of noting the salient details that it would want to find in the future.

    wp-content/plugins/plugin-name/CLAUDE.md
    Markdown
    ## Build System
    Uses `@wordpress/scripts` (wraps webpack + Babel). The `--blocks-manifest` flag auto-generates `build/blocks-manifest.php`.
    ```bash
    npm run build # Production build
    npm run start # Development watch mode
    npm run lint:js # JavaScript lint
    npm run lint:css # CSS/SCSS lint
    npm run format # Auto-format code
    npm run plugin-zip # Create distributable zip
    ```
    > Always run a build before testing PHP-side block registration changes — the manifest is generated at build time.
    ## PHP Linting (WPCS / PHPCS)
    PHP coding standards are enforced via [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards). Config is in `phpcs.xml.dist`.
    ```bash
    composer install # First time — installs PHPCS + WPCS into vendor/
    composer lint:php # Run PHPCS
    composer lint:php:fix # Run PHPCBF (auto-fix)
    ```

    Document your build system, along with relevant commands for Claude to use.

    wp-content/plugins/plugin-name/CLAUDE.md
    Markdown
    ## Code Style
    This project follows **WordPress Core coding standards** throughout, enforced by linting.
    ### PHP
    - [WordPress PHP Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/): Yoda conditions, `array()` (not `[]`), tabs for indentation, spaces inside parentheses, `snake_case` for functions/variables, `PascalCase` for classes.
    - Escape all output (`esc_html__()`, `esc_url()`, `esc_attr()`). Sanitize all input (`sanitize_text_field()`, `wp_unslash()`). Use `check_admin_referer()` for nonce verification before processing POST data.
    - No direct database calls; use WordPress option and transient APIs.
    ### JavaScript / JSX
    - [WordPress JavaScript Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/javascript/): tabs for indentation, spaces inside braces and parentheses, single quotes.
    - Use `const`/`let`, never `var`.
    - All user-visible strings must be wrapped in `__()` or `sprintf()` from `@wordpress/i18n`. `sprintf()` calls require a `/* translators: ... */` comment **inside the same JSX expression block** as the call (not on a separate line).
    - Avoid flanking whitespace in translation strings — use `{ ' ' }` for explicit spaces between JSX nodes.
    - `/* global SomeGlobal */` comments are required for browser globals not in the default ESLint environment (e.g. `MutationObserver`), and for WP-localized script globals (e.g. `bolPardot`).
    ### CSS / SCSS
    - [WordPress CSS Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/css/): tabs for indentation, space before `{`, lowercase properties.
    - Order pseudo-class selectors least-specific first: `&:disabled` before `&:focus` before `&:hover`.
    - Avoid `input, textarea` combined rules when textarea needs additional properties — use separate blocks to satisfy `no-descending-specificity`.
    - Use CSS custom property fallback chains: block-level `--bol-*` first, then `--global-palette*` (Kadence, if active), then `--wp--preset--*`, then a hardcoded default. Never rely on any single source being present.

    It’s worthwhile to set up not only this but also something like Prettier to normalize things as you go — and doing so will avoid some IDEs accidentally using spaces instead of tabs or the like and leading to inconsistencies and extra fixer commits down the road.

    wp-content/plugins/plugin-name/CLAUDE.md
    Markdown
    ## Debugging approach
    - **Check actual output before speculating.** When diagnosing a frontend issue, make an HTTP request to the local site (`https://pardot.wp.local/`) and inspect the rendered HTML or loaded CSS first. This is faster and more reliable than reasoning about what the output might be.
    - **Ask the user questions early.** If a problem has multiple possible causes, ask a targeted question rather than running through all hypotheses. The user can often point you straight to the answer.

    I’ve seen Claude get incredibly introspective sometimes — it will consider if the bug being reported actually existed, it will do entire rabbit holes of considering potential problems on a five minute loop, when it could just ask me a five second question and continue on.

    Agentics can’t take Vyvanse, so we’ll try to keep it on task and have it communicate when it needs to.

    wp-content/plugins/plugin-name/CLAUDE.md
    Markdown
    ## Before marking any task as done
    1. Run **both** linters and fix any reported issues:
    ```bash
    npm run lint:js && npm run lint:css
    composer lint:php
    ```
    2. If the task adds or changes user-visible behaviour, update the **Help tab** in `includes/class-bol-admin-page.php` (`render_help_tab()`).
    3. Update **`CLAUDE.md`** and **`.github/copilot-instructions.md`** with any architectural changes.
    4. If any translatable strings were added, changed, or removed (in PHP or JS), regenerate the POT file:
    ```bash
    studio wp --path=/Users/georgestephanis/Studio/pardot i18n make-pot wp-content/plugins/big-orange-pardot wp-content/plugins/big-orange-pardot/languages/big-orange-pardot.pot --domain=big-orange-pardot
    ```

    Modern projects have many moving parts — and something like this helps to keep all the associated bits linked together properly and updated as needed.

    The easiest way to set up WPCS is just to ask Claude (or Copilot, or others) to do it for you.

    Conclusion

    Hopefully this will give you some useful ideas for how to optimize your workflows and build tools. Have I missed some useful steps that you’ve found beneficial? Please let me know in the comments below and share your expertise with everyone!

  • WP REST API vs GraphQL Performance

    A client had recently pointed out to me a post detailing performance differences between the REST API that ships in WordPress Core, and WPGraphQL —

    https://www.wpgraphql.com/docs/wpgraphql-vs-wp-rest-api#performance

    In it, it asserts that the difference between two similar queries running the REST API and WPGraphQL yields vastly different performance impacts:

    Below are screenshots of the same WordPress site asking for 100 posts from the WP REST API and 100 posts from WPGraphQL with the Chrome network tab open.

    REST:

    • Download size: 335 kb
    • Time: 7.91s

    WPGraphQL

    • Download size: 6.4 kb
    • Time: 67 ms

    That seemed like an unrealistic difference from an initial glance — the REST API takes over 100x longer to return data than WPGraphQL? I wanted to check and see if I could duplicate the test and find out what accounted for the orders of magnitude difference between the two.

    (more…)
    Fediverse Reactions
  • Jetpack Infinite Scroll for Single Posts!

    Had a problem come up recently where folks wanted to keep engaging visitors on their website on a single post page — keep loading more posts afterwards when they kept scrolling.

    I’ve heard of this before, and even seen some plugins accomplish it — for example, Infinite Post Transporter, by Tom Harriganwp.org / github — the codebase of which looks to be a modified version of Jetpack’s Infinite Scroll from about six years ago — (contemporary link).

    So, I was curious to see how far I could go about getting close to what we needed just by playing with Jetpack’s own library, rather than duplicating a bunch of the code in a second plugin.

    For anyone that wants to skip to the end and just get something to play with, here’s the gist that I’ve got the code shoved into for now.

    First, a couple specifications we’re working with here:

    • I want to make this work on single post pages, specifically of the post post_type.
    • I don’t want to modify Jetpack files, or deregister / replace them with customized versions of the files. Filters / actions only.

    So, skimming through the Jetpack Infinite Scroll codebase, there’s a couple conditionals we’re gonna need to short out to get things triggering on single post pages.

    The bulk of the per-page code comes from The_Neverending_Home_Page::action_template_redirect() — this is the function that will register/enqueue Infinite Scroll’s scripts and styles, and set up footer actions to populate some javascript globals that specify state. However, for single posts, we’ll need to override two points.

    		if ( ! current_theme_supports( 'infinite-scroll' ) || ! self::archive_supports_infinity() )
    			return;
    

    By default, single posts aren’t archives, and don’t work. So let’s hotwire it! Digging into The_Neverending_Home_Page::archive_supports_infinity() we can see that the whole response to that call is simply passed through a filter — `infinite_scroll_archive_supported`

    		/**
    		 * Allow plugins to filter what archives Infinite Scroll supports.
    		 *
    		 * @module infinite-scroll
    		 *
    		 * @since 2.0.0
    		 *
    		 * @param bool $supported Does the Archive page support Infinite Scroll.
    		 * @param object self::get_settings() IS settings provided by theme.
    		 */
    		return (bool) apply_filters( 'infinite_scroll_archive_supported', $supported, self::get_settings() );
    

    So to filter this, we’ll want to make sure we’re using the right conditionals and not filtering too many pages. In this case, is_singular( 'post' ) feels appropriate.

    For brevity here, I’ll just be using anonymous functions, but in practice it’s likely far better to name your functions so other code can remove the filters if it becomes necessary.

    add_filter( 'infinite_scroll_archive_supported', function ( $supported ) {
    	if ( is_singular( 'post' ) ) {
    		return true;
    	}
    	return $supported;
    } );
    

    Groovy, now we’ve got the function registering the scripts we care about. But there’s a second conditional later in the ::action_template_redirect() method that we also get snagged on — ::is_last_batch().

    		// Make sure there are enough posts for IS
    		if ( self::is_last_batch() ) {
    			return;
    		}
    

    Fortunately, like our last example, The_Neverending_Home_Page::is_last_batch() is also filterable.

    		/**
    		 * Override whether or not this is the last batch for a request
    		 *
    		 * @module infinite-scroll
    		 *
    		 * @since 4.8.0
    		 *
    		 * @param bool|null null                 Bool if value should be overridden, null to determine from query
    		 * @param object    self::wp_query()     WP_Query object for current request
    		 * @param object    self::get_settings() Infinite Scroll settings
    		 */
    		$override = apply_filters( 'infinite_scroll_is_last_batch', null, self::wp_query(), self::get_settings() );
    		if ( is_bool( $override ) ) {
    			return $override;
    		}
    

    So again, we can just override it as we had before, only this case returning false:

    add_filter( 'infinite_scroll_is_last_batch', function ( $is_last_batch ) {
    	if ( is_singular( 'post' ) ) {
    		return false; // Possibly retool later to confirm there are other posts.
    	}
    	return $is_last_batch;
    } );
    

    Great! So now we’ve got the scripts that do the work being output on our single posts page, but we’re not quite there yet! We need to change some of the variables being passed in to Jetpack’s Infinite Scroll. To modify those variables, we have — you guessed it — another filter.

    The JS Settings are being output in The_Neverending_Home_Page::action_wp_footer_settings() (rather than being added via wp_localize_script), and here’s the filter we’ll be working off of:

    		/**
    		 * Filter the Infinite Scroll JS settings outputted in the head.
    		 *
    		 * @module infinite-scroll
    		 *
    		 * @since 2.0.0
    		 *
    		 * @param array $js_settings Infinite Scroll JS settings.
    		 */
    		$js_settings = apply_filters( 'infinite_scroll_js_settings', $js_settings );
    

    so we’ll start with a generic action like this, and start customizing:

    add_filter( 'infinite_scroll_js_settings', function ( $js_settings ) {
    	if ( is_singular( 'post' ) ) {
    		$js_settings['foo'] = 'bar'; // any we need to make!
    	}
    	return $js_settings;
    } );
    

    We can verify this change is in place by loading up a single post page, and searching for foo — confirming that it’s in the encoded string that’s being output to the page. So now we need to start looking at the javascript that runs and see what changes to inputs we may need to make.

    First, we’ll need to make sure that the wrapper container we want to append our new posts to is the same as the ID that we use on archive pages. If it’s not, we just need to override that. In my case, I had to change that to main on the single post pages — which can be done like so:

    $js_settings['id'] = 'main';
    

    If your wrapper id is the same as archive pages, this can just be skipped. Here we can also override some other options — for example if we would rather change the verbiage on the load more posts button we could do

    $js_settings['text'] = __( 'Read Next' );
    

    or to switch from the button click, to autoloading on scroll we could do

    $js_settings['type'] = 'scroll';
    

    Now, if you tried what we have so far, you may notice that instead of getting new posts, you’ll likely see the same post loading over and over forever. That’s because of the parameters getting passed through as query_args — if you pop open your browser window to examine infiniteScroll.settings and look at the query_args param, you’ll likely notice that we’ve gotten the infiniteScroll.settings.query_args.name populated! This is getting passed directly to the ajax query, so when trying to pull up more posts, it’s restricting it to only the already queried post, as that’s from the query WordPress ran to generate the current url’s response.

    So let’s just nuke it, and for good measure ensure we don’t inadvertently re-display the post in question.

    $js_settings['query_args']['name'] = null;
    $js_settings['query_args']['post__not_in'][] = get_the_ID();
    

    Cool cool cool. So at this point, when you run the post, everything /should/ look about right, except for one small thing! You may see the url updating as you scroll to append /page/2/ to the path!

    Unfortunately a lot of this functionality is hard-coded and I couldn’t find a good way to override it to update the url to — for example — the url of the post you’ve currently got in view, so I wound up doing the next best thing — nuking the functionality entirely.

    	/**
    	 * Update address bar to reflect archive page URL for a given page number.
    	 * Checks if URL is different to prevent pollution of browser history.
    	 */
    	Scroller.prototype.updateURL = function ( page ) {
    		// IE only supports pushState() in v10 and above, so don't bother if those conditions aren't met.
    		if ( ! window.history.pushState ) {
    			return;
    		}
    		var self = this,
    			pageSlug = self.origURL;
    
    		if ( -1 !== page ) {
    			pageSlug =
    				window.location.protocol +
    				'//' +
    				self.history.host +
    				self.history.path.replace( /%d/, page ) +
    				self.history.parameters;
    		}
    
    		if ( window.location.href != pageSlug ) {
    			history.pushState( null, null, pageSlug );
    		}
    	};
    

    As the js relies on rewriting the url via calling .replace() on a string, if we eat the token it searches for off the end, it’ll just wind up replacing the url with itself, and doesn’t look awkward. And so —

    $js_settings['history']['path'] = str_replace( 'page/%d/', '', $js_settings['history']['path'] );
    

    So, functionally this should get you most of the way there. The only other bit that I had added to my implementation was something that could trivially be done anywhere — Custom CSS in the Customizer, or theme files — but I wanted to hide Post Navigation links in my theme. So I just did a fun little trick of enqueueing my one line css tweak (with appropriate conditionals) like so:

    add_action( 'template_redirect', function () {
    	if ( ! class_exists( 'Jetpack' ) || ! Jetpack::is_module_active( 'infinite-scroll' ) ) {
    		return;
    	}
    
    	if ( is_singular( 'post' ) ) {
    		// jisfsp = Jetpack Infinite Scroll for Single Posts
    		wp_register_style( 'jisfsp', null );
    		wp_enqueue_style( 'jisfsp' );
    		wp_add_inline_style( 'jisfsp', 'nav.post-navigation { display: none; }' );
    	}
    } );
    

    This way, if someone disables either Jetpack or infinite scroll, the conditional trips and it stops hiding post navigation links.

    I hope this has been somewhat useful to someone. It’s probably not at the point where I’d be comfortable packaging it up as a plugin for end-user installation, but if you’ve got a passing understanding of code and how WordPress works, it shouldn’t be difficult to re-implement for a client. If there’s any other tweaks on Infinite Scroll that you wind up finding and would like to suggest, please feel free to leave a comment below, and it’ll hopefully be useful to others. Cheers!

  • Why Jetpack isn’t a Collection of Plugins, Part the First

    In keeping with a previous post I’d made a couple months ago explaining the oft-discussed rationale of why we do things the way we do with Jetpack, I’ll be doing it again today, on a different — but related — topic.

    I may as well make a series of it.

    This is the first of two posts (in theory, I’ll remember to write the second) explaining why Jetpack is a big plugin with many features, rather than many individual plugins.  This post will be looking at the primary technical reason.  The abundance of other reasons will be in the subsequent post.  (So please don’t read this post and think it’s the only reason — it’s not)

    tl;dr: Dependency management sucks.

    Jetpack, as you may be aware, is structured as a bunch of modules.  Many — but not all — require a connection to WordPress.com to function.  This isn’t for vanity purposes, it’s because they actually leverage the WordPress.com server infrastructure to do things harder, better, faster, stronger than a $5/month shared host is capable of.  To do that, they need to be able to communicate securely with WordPress.com, and WordPress.com must be able to communicate securely back to your site.

    Some of the modules that require a connection are things such as Publicize (which uses the WordPress.com API keys to publicize to assorted third-party systems, rather than making users register various developer accounts and get their own API keys), Related Posts (which syncs some content up to the WordPress.com servers and indexes it on a large ElasticSearch index more efficiently and accurately than could be done in a MySQL database), Monitor (which pings your site every five minutes and emails you if it’s down), Comments (which passes data back and forth behind the scenes to enable secure third-party comment authentication) — you get the idea.

    We could bundle the connection library with each individual plugin.  However, we’d need to make sure it was namespaced correctly so each different plugin can use its own correctly versioned instance of the connection classes.  Which would then mean a user could have well over a dozen copies and different versions of the same connection class active at a given time.  Which will make things more difficult with respect to developing the plugins, as you can’t assume methods in one are necessarily in another.  And when you make a change in the master class, you need to scan each repository to make sure you’re not breaking anything there, and keep changes synced to well over a dozen repositories.  But I digress.

    To avoid duplicate code, the modules that depend on talking back and forth with WordPress.com all use a common library that handles signing and verifying requests, API calls, and the like.

    Because it’s all packaged in a single plugin, we can be sure that it’s all running the required version.  If Publicize needs a change in the core connection library, we can be sure that the version of the connection library in Jetpack has those changes.  If the core connection library needs to change structure, we can make sure that any modules that used the old methods are updated to run the new ones instead.  Everything is maintained so that it’s running smoothly and works properly with each other.

    Now, if Likes, Single Sign On, After the Deadline, Post by Email and others were their own plugins, and connected to a separate Jetpack Core plugin, versioning gets tricky.  It could work, in theory, if every plugin is kept up to date, always and forever.  But the instant that the user is using, say, an outdated version of Subscriptions with an outdated Jetpack Core (which work perfectly together), and then installs the up-to-date WP.me Shortlinks plugin, things could break because WP.me Shortlinks expects a more up-to-date Jetpack Core.  So you go ahead and update Jetpack Core to current, but now Subscriptions — which used to work perfectly — now breaks because there was a method change in Jetpack Core, that is fixed in the up-to-date version of Subscriptions, but the user isn’t running the up-to-date version.  Horrible UX.

    Plus, if the user doesn’t have any Jetpack stuff, the installation flow for their first Jetpack Plugin that needs the core would be something like this:

    1. Install Stats.
    2. Activate Stats.
    3. Get error saying you need Jetpack Core for Stats to function.
    4. WTF is Jetpack Core? I just want Stats!
    5. Okay, install Jetpack Core.
    6. Activate Jetpack Core.
    7. Wait, what was I doing?
    8. Stats!  Okay, right.
    9. Connect the Jetpack Core to WordPress.com.
    10. Drink Scotch in celebration.

    Compare this to the status quo of:

    1. Install Jetpack.
    2. Activate Jetpack.
    3. Connect Jetpack to WordPress.com.
    4. Stats is already active (unless you’re Mark Jaquith, in which case you activate it in this step)
    5. Drink Scotch in celebration.

    As I said, dependency management is hard, and there’s not really a good way to manage it in WordPress.  There have been some very worthwhile attempts made, but none that can have a sufficiently solid user experience for an average user to compare with our current system and flow.

    Any questions or suggestions about dependency management and Jetpack? Ask away!

  • Better handling of RTL stylesheets in plugins and themes

    Or, How I Learned To Stop Worrying and Let Core Do The Work For Me.


    If you’ve ever done something like this, we should have a little chat:

    if ( is_rtl() ) {
    	wp_register_style( 'example', plugins_url( "css/example-rtl.css", __FILE__ ) );
    } else {
    	wp_register_style( 'example', plugins_url( "css/example.css", __FILE__ ) );
    }
    wp_enqueue_style( 'example' );
    

    Now, don’t worry. I’ve done it too! It’s not that big of a deal. But there is a better, tidier way. Just compare the above code blurb to this:

    wp_register_style( 'example', plugins_url( "css/example.css", __FILE__ ), array(), '1.0' );
    wp_style_add_data( 'example', 'rtl', 'replace' );
    wp_enqueue_style( 'example' );
    

    which will output something like this in rtl locales:

    <link rel='stylesheet' id='example-rtl-css' href='http://domain.com/path/to/css/example-rtl.css' type='text/css' media='all' />
    

    Simpler, right? It reads more easily, and as an added bonus, if something is to toggle RTL after you’ve registered the path to the asset, it handles it gracefully! As it doesn’t determine which asset path to serve up until it’s actually outputting the tag.

    Now, this is assuming that your rtl stylesheet is just a replacement for your normal stylesheet. Which most are — it could be automatically generated with some tool like CSSJanus or CSS-Flip. But if you’ve got an add-on css file, that you want to load in addition that just contains overrides for RTL languages, you can handle that just as easily!

    wp_register_style( 'example', plugins_url( "css/example.css", __FILE__ ), array(), '1.0' );
    wp_style_add_data( 'example', 'rtl', 'addon' );
    wp_enqueue_style( 'example' );
    

    which will output something like this in rtl locales:

    <link rel='stylesheet' id='example-css' href='http://domain.com/path/to/css/example.css' type='text/css' media='all' />
    <link rel='stylesheet' id='example-rtl-css' href='http://domain.com/path/to/css/example-rtl.css' type='text/css' media='all' />
    

    For the curious as to how Core actually does it, read here:

    https://github.com/WordPress/WordPress/blob/809baf442b/wp-includes/class.wp-styles.php#L88-L104

    Detailed explanation (with bonus examples for handling minified versions of both regular and rtl css as well):

    /**
     * If you're supplying a pre-minified version of the stylesheet, you'll
     * need this, and to add the `suffix` data, so that core knows to
     * replace `example-min.css` with `example-rtl-min.css` -- handling
     * the suffix properly.
     */
    $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
    
    /**
     * The normal registration. You're familiar with this already.
     */
    wp_register_style( 'example', plugins_url( "css/example{$min}.css", __FILE__ ), array(), '1.0' );
    
    /**
     * I set the value to 'replace', so it will replace the normal css file if rtl,
     * but it could also be 'addon' for a css file that just gets enqueued as
     * well, rather than replacing the normal one.
     */
    wp_style_add_data( 'example', 'rtl', 'replace' );
    
    /**
     * Finally, if we are replacing the existing file, and there's some sort of
     * suffix like `-min` as mentioned earlier, we need to let core know about
     * it, so that it can keep that suffix after the added `-rtl` that it's adding to
     * the path.
     */
    wp_style_add_data( 'example', 'suffix', $min );
    
    /**
     * Then we just enqueue it as we would normally!  If it's going to always
     * be enqueued regardless, we could just call `wp_enqueue_style()` rather
     * than `wp_register_style()` above.
     */
    wp_enqueue_style( 'example' );
    
  • On Jetpack and Auto-Activating Modules

    Hopefully, this is the last time that I’ll have to answer this question.

    Frankly, it’s been answered dozens of times before. Now, I’m hoping to use this as a canonical ‘Answer Link’ that I can refer people to.  I’ll keep up with comments, so if anyone would like to ask

    So, why does Jetpack auto-activate features?

    Well, to start off, I should probably clarify what we currently do on this. We don’t auto-activate every new module that comes in.

    We never auto-activate features that affect the display or front-end of your site — or at least not unless a site administrator explicitly configures them to.

    So, for example, something like Photon, which would swap all your content images to CDN-hosted versions, doesn’t auto-activate. Our comments system doesn’t auto-activate either, as that would swap out your native comment form. Our sharing buttons do, but they don’t display unless you take the time to drag down some sharing buttons to the output box under Settings > Sharing.

    However, modules like Publicize, Widget Visibility, and the like — they just give you new tools that you can use, with no risk to affecting your everyday visitors. When users upgrade, we give them a notification of what just happened, and point out some new features we’ve built in that they may want to activate themselves.

    One thing we’ve recently expanded on, perhaps six months ago, is a ‘plugin duplication list’, for lack of a better phrase. These aren’t plugins that have an actual code-based conflict with a module, they’re ones that may be … duplicating effort. Previously, we were just scanning for plugins that would output OG Meta Tags, and short-circuit our own provider. However, since Jetpack 2.6, which shipped in November 2013, we’re actually doing it via a filter for all modules. For example, if you’ve got Gravity Forms or Contact Form 7 installed and active, our internal Jetpack Contact Form won’t auto-activate. If you’ve got AddThis or ShareThis active, our sharing buttons module won’t even kick in.

    Now, obviously, we can’t catch every single plugin that may be similar enough to one of our modules to give cause to negate auto-activation. So there’s a filter, `jetpack_get_default_modules`, that can be used in any plugin to cancel auto-activation on any module.

    
    add_filter( 'jetpack_get_default_modules', 'my_jetpack_get_default_modules' );
    function my_jetpack_get_default_modules( $modules ) {
        return array_diff( $modules, array( 'module-slug' ) );
    }
    
    

    But I don’t like auto-activation of new features!

    Okay.

    You’re totally allowed not to.

    We’re going to continue using our discretion to auto-activate select modules by default, but if you’d like to turn it off permanently for yours or a client’s site, we’ve made it ridiculously easy to do.

    
    add_filter( 'jetpack_get_default_modules', '__return_empty_array' );
    
    

    That’s it.

    We believe that judiciously enabling new features is a win for users, especially considering 1) how low-impact most features are when ‘active’ but not actually implemented by a site owner, 2) how awkward it is for a site owner to have to enable something twice — for example, enabling the Custom Post Formats bit, and then having to visit Settings > Writing in order to actually enable the Portfolio custom post type.

    We’ve spoken to many, many users who find a default feature set convenient, and resent having to make a bunch of ‘decision points’ if they had to manually activate each and every module. Good software should run well out of the box. So we’ve set up the defaults as we have. Yes, some people disagree and are vocal about not wanting anything to auto-activate. That’s okay. We try to design for the majority, with the best user experience we can provide.

    If you have clients, that you’d like to be active in the relationship with, and customize the Jetpack experience for — that’s terrific. You’re the type of people that we add bunches of filters for. We’re all about empowering you to override our decisions, we just prefer to keep the default user interface free of a thousand toggles.

    Decisions, not options — no?

  • Decisions

    Decisions are never easy.

    Even if it’s an entirely trivial matter, it’s still forcing you to do something.

    And goshdarnit, I’m lazy.

    I also prefer front-loading effort when possible. Ounce of prevention, pound of cure, stitch in time saving nine, and all that.

    And I respect other people’s time as much as I value my own. So when I build something, I try to avoid decision points whenever possible. This results in the loss of options occasionally, but I believe a smoother user flow.

    Now, occasionally power-users will want to modify functionality. Adding a decision point for all users for the sake of the minority is silly, especially when power-users can leverage other methods — filters, actions, functionality plugins that extend the first plugin — to accomplish their goals.

    To each according to their needs. Typical users need a simple, smooth, classy interface. Power users need to get under the hood. Why try to make something that doesn’t work well for either by trying to serve both?

    The best middle ground I’ve been able to come up with is offering a secondary ‘under the hood’ plugin that exposes a lot of filters as options. Keep it canonical and clean, but present all the options.

    Ideal? Not really. Workable? Probably.

  • Events Custom Post Type Proposal for Multisite

    This is intended for the Make.WordPress.org series of blogs.  There are a number of needs, from weekly chat schedules for some Make blogs, to WordCamps for others.  Each site needs to be able to display their own events, and the main Make site would need to be able to display an aggregate of all (or some) of the sub-sites.

    I see the implementation of the output (data structures will be addressed separately) being done via a shortcode, as follows:

    [super-spiffy-event-calendar]

    which would do stuff roughly like:

    $events = Super_Spiffy_Event_Calendar::get_events();
    Super_Spiffy_Event_Calendar::render_plugins( $events );
    

    Not really tricky.  That will display any events from the multisite blog that you happen to be on.  However, for the aggregate, I see something more akin to this:

    [super-spiffy-event-calendar blog_ids="2,3,4,5,6,13,14,19,22"]

    which would be more akin to:

    $original_blog_id = get_current_blog_id();
    $events = array();
    foreach ( $blog_ids as $blog_id ) {
    	switch_to_blog( $blog_id );
    	$events = array_merge( $events, Super_Spiffy_Event_Calendar::get_events() );
    }
    switch_to_blog( $original_blog_id );
    Super_Spiffy_Event_Calendar::render_plugins( $events );
    

    A couple things we’d need to add in that aren’t noted here:

    • Caching. Shove it in a transient, so we’re not doing an expensive operation with blog switching on every page load.
    • Sorting. After building the aggregate, it’s probably worth sorting the events chronologically.
    • Display. I’d like to use http://arshaw.com/fullcalendar/ or something similar to handle the output.