# MM Post Grid — developer documentation

A responsive post grid shortcode for WordPress, built to stay small, accessible and easy to extend.

- **Version:** 0.1
- **Requires:** WordPress 6.1+, PHP 7.4+
- **Author:** [Mehraz Morshed](https://mehrazmorshed.com/)
- **Plugin URI:** https://mmweb.net/mm-post-grid/
- **License:** GPL-2.0-or-later
- **Text domain:** `mm-post-grid`

---

## Table of contents

- [File structure](#file-structure)
- [Architecture](#architecture)
- [Data flow](#data-flow)
- [Settings](#settings)
- [Styling templates](#styling-templates)
- [Filters](#filters)
- [Actions](#actions)
- [CSS custom properties](#css-custom-properties)
- [Modifier classes](#modifier-classes)
- [Markup reference](#markup-reference)
- [Recipes](#recipes)
- [Coding standards](#coding-standards)
- [Roadmap](#roadmap)

---

## File structure

```
mm-post-grid/
├── mm-post-grid.php              Plugin header, constants, autoloader, bootstrap
├── uninstall.php                 Opt-in data removal, multisite aware
├── readme.txt                    WordPress.org readme
├── README.md                     This file
├── includes/
│   ├── Plugin.php                Wiring only: hooks and object graph
│   ├── Settings.php              Option schema, defaults, reads, sanitisation
│   ├── Helpers.php               Casting, list parsing, presets, CSS guard
│   ├── Args.php                  Shortcode and REST input normalisation
│   ├── Query_Builder.php         WP_Query arguments, taxonomy clauses, page count
│   ├── Renderer.php              All front end markup
│   ├── Assets.php                Conditional enqueue, settings driven CSS
│   ├── REST_Controller.php       Load More endpoint
│   └── Templates.php             Styling templates: data, matching, apply
├── admin/
│   ├── Settings_Page.php         Menu, tabs, reset handler, help sidebar
│   └── Settings_Fields.php       Settings API registration and field rendering
├── public/
│   └── Shortcode.php             Shortcode registration
├── assets/
│   ├── css/mmpg-frontend.css     Grid styles, all scoped to .mmpg
│   ├── css/mmpg-admin.css        Settings screen styles
│   ├── js/mmpg-load-more.js      Vanilla Load More enhancement
│   └── js/mmpg-admin.js          Colour pickers, copy button, reset confirm
└── languages/
    └── mm-post-grid.pot          Translation template
```

## Architecture

### Namespacing and autoloading

Classes are namespaced under `MM_Post_Grid` and map to directories PSR-4 style:

| Class | File |
| --- | --- |
| `MM_Post_Grid\Renderer` | `includes/Renderer.php` |
| `MM_Post_Grid\Admin\Settings_Page` | `admin/Settings_Page.php` |
| `MM_Post_Grid\Frontend\Shortcode` | `public/Shortcode.php` |

The autoloader is a short prefix map in `mm-post-grid.php`. There is no Composer dependency and no `vendor` directory: the plugin ships exactly the code it runs.

WPCS would normally want `class-renderer.php` here. The development ruleset (see [Coding standards](#coding-standards)) excludes `WordPress.Files.FileName` for that reason and documents why inline. Every other WordPress rule, including the entire escaping and security ruleset, stays enabled.

### Responsibilities

Each class owns one job and does not reach into another's:

- **`Settings`** is the only place that knows the option schema. It holds types and defaults, no translated strings, so reading settings on the front end never triggers translation loading. Labels live in `Settings_Fields`.
- **`Args`** is the single trust boundary. Both the shortcode and the REST route funnel through it, so there is one place where input is validated rather than one per entry point.
- **`Query_Builder`** turns normalised args into `WP_Query` arguments. It never outputs anything.
- **`Renderer`** is the only class that produces front end markup. It is static and takes a plain array, which is what makes a future block a thin wrapper rather than a rewrite.
- **`Assets`** decides whether anything is loaded at all.

## Data flow

```
[mm_post_grid …]
      │
      ▼
Frontend\Shortcode ──► Args::from_shortcode()  ─┐
                                                │ normalise + validate
REST /grid?grid=… ───► Args::from_request()   ──┘
      │
      ▼
Query_Builder::build() ──► mmpg_query_args ──► WP_Query
      │
      ▼
Renderer::grid() ──► Renderer::cards() ──► Renderer::card()
      │
      ▼
Assets::enqueue()  (style + only the changed custom properties)
```

### The trust boundary

`Args::normalize()` takes a `$trusted` flag. Shortcode attributes come from a site editor and are trusted for things like post status; REST input is not. On the untrusted path:

- `post_status` is forced to `publish`
- post types must pass `is_post_type_viewable()`
- taxonomies must be public
- `posts_per_page` is capped at 48 and `page` at 500
- the JSON payload is rejected over 4000 bytes

That means a crafted request cannot make the endpoint return anything the same visitor could not already see on the page.

### About the public REST route

`mm-post-grid/v1/grid` uses `permission_callback => __return_true`. This is deliberate and worth understanding before you change it:

- The route only ever returns published posts of publicly viewable types — exactly what the page itself renders.
- Requiring a nonce would break Load More on any site behind full page caching, because a cached page serves a stale nonce. That produces support tickets, not security.
- A nonce **is** sent for logged in users, so their REST context is preserved.

The security guarantee comes from re-validating every argument server side, not from the permission callback.

### Asset loading

Handles are registered on `wp_enqueue_scripts`. If `has_shortcode()` finds the shortcode in the queried singular post's content, the style is enqueued there and prints in the head. Otherwise it is enqueued while the shortcode renders, and WordPress prints it in the footer.

The trade-off: grids in widgets, templates or page builder content get their CSS in the footer, which can cause a brief unstyled flash. The alternative — loading the stylesheet everywhere — was rejected as worse. If your grids are always in a template, enqueue the handle yourself:

```php
add_action( 'wp_enqueue_scripts', function () {
	if ( is_home() ) {
		MM_Post_Grid\Assets::enqueue();
	}
} );
```

### Performance notes

- `update_post_thumbnail_cache()` primes thumbnail data in one query rather than one per card.
- `no_found_rows` is set when pagination is off, skipping the row count query.
- `update_post_meta_cache` is deliberately **not** disabled. Saving one query is not worth the N+1 risk when a theme or another plugin reads post meta inside a card filter.
- Inline CSS is diffed against the shipped defaults, so an unmodified site emits none.

## Settings

All settings live in a single option, `mmpg_settings`, as an array. Read them through the class rather than `get_option()` so defaults and caching apply:

```php
$columns = MM_Post_Grid\Settings::get( 'columns' );
$all     = MM_Post_Grid\Settings::all();
```

`Settings::sanitize()` only touches keys present in the submitted data, so saving one tab never wipes another. Every checkbox is paired with a hidden `0` input, so unchecking actually saves.

## Styling templates

`MM_Post_Grid\Templates` holds seven templates: Default, Minimal, Soft, Outline, Dark, Magazine and List. Three design rules drive the class:

- **A template is a starting point, not a mode.** Applying one writes its values into `mmpg_settings`; nothing about the template is stored separately. There is no hidden layer that could override a later edit on the Styling tab.
- **Templates are deterministic.** Each template lists only the values it changes, and `Templates::values()` fills every other styling key with its shipped default. The result never depends on what was configured before.
- **Only the Styling tab changes.** `Templates::STYLE_KEYS` lists exactly the styling keys. Query, layout and content settings are never touched. The smoke test asserts that this list and the Styling tab fields stay identical.

`Templates::active()` returns the template the current styling matches exactly, or an empty string once anything has been fine-tuned. The Templates tab uses it for the Active badge.

### Registering a template

```php
add_filter( 'mmpg_templates', function ( $templates ) {
	$templates['brand'] = array(
		'label'       => 'Brand',
		'description' => 'Our colours, square corners.',
		'values'      => array(
			'accent_color'  => '#10b981',
			'card_radius'   => 0,
			'button_style'  => 'button',
			'button_bg'     => '#047857',
		),
	);
	return $templates;
} );
```

Values are sanitized against the setting schema exactly like a form submission, keys that are not styling keys are ignored, and slugs pass through `sanitize_key()`.

## Filters

### Query

**`mmpg_query_args`** — `WP_Query` arguments before the query runs.

```php
add_filter( 'mmpg_query_args', function ( $query_args, $args ) {
	$query_args['ignore_sticky_posts'] = true;
	return $query_args;
}, 10, 2 );
```

**`mmpg_shortcode_defaults`** — default attribute values. Add your own attributes here; they arrive in `$args` in every other filter.

**`mmpg_settings_schema`** — the option schema. Add a key and it is sanitised and defaulted like a built-in one.

### Markup

Each of these receives the rendered string, the post, and the normalised args.

| Filter | Filters | Parameters |
| --- | --- | --- |
| `mmpg_grid_html` | The whole grid | `$html, $args, $query` |
| `mmpg_grid_classes` | Wrapper class array | `$classes, $args` |
| `mmpg_card_html` | One card | `$html, $post, $args` |
| `mmpg_card_classes` | Card class array | `$classes, $post, $args` |
| `mmpg_card_title` | Title text | `$title, $post, $args` |
| `mmpg_card_media` | Media block | `$html, $post, $args` |
| `mmpg_card_meta` | Date and author line | `$html, $post, $args` |
| `mmpg_card_excerpt` | Excerpt text | `$excerpt, $post, $args` |
| `mmpg_card_taxonomy` | Taxonomy used for the term line | `$taxonomy, $post, $args` |
| `mmpg_card_terms_limit` | How many terms to show | `$limit, $post, $args` |
| `mmpg_read_more_text` | Read more label | `$text, $post, $args` |
| `mmpg_empty_message` | No results message | `$message, $args` |
| `mmpg_inline_css` | Inline custom properties | `$css, $settings` |
| `mmpg_templates` | Styling templates | `$templates` |
| `mmpg_allowed_html` | Tags and attributes kept by `wp_kses()` | `$allowed` |

Markup filters may return HTML, and the plugin escapes the final result: the shortcode callback and the load more REST response both pass the grid through `wp_kses()` with `Renderer::allowed_html()`. That list is exactly the markup the grid produces, so if your filter adds other tags or attributes, add them with `mmpg_allowed_html`:

```php
add_filter( 'mmpg_allowed_html', function ( $allowed ) {
	$allowed['svg']  = array( 'class' => true, 'viewbox' => true, 'aria-hidden' => true );
	$allowed['path'] = array( 'd' => true );
	return $allowed;
} );
```

The generic `wp_kses_post()` list is deliberately not used: it strips `srcset`, `sizes`, `decoding` and `fetchpriority` from images, which would silently turn off responsive images. WordPress 6.1 is the minimum because that is the first version whose `style` attribute filter accepts CSS custom properties, which carry the per grid overrides.

## Actions

| Action | Fires | Parameters |
| --- | --- | --- |
| `mmpg_before_grid` | Before the wrapper opens | `$args, $query` |
| `mmpg_after_grid` | After the wrapper closes | `$args, $query` |
| `mmpg_before_card` | Before each card | `$post, $args` |
| `mmpg_after_card` | After each card | `$post, $args` |

## CSS custom properties

Set on `.mmpg`. Override them anywhere with normal specificity — no `!important` needed, because the plugin does not use it.

| Property | Default |
| --- | --- |
| `--mmpg-columns` | `3` |
| `--mmpg-columns-tablet` | `2` |
| `--mmpg-columns-mobile` | `1` |
| `--mmpg-gap` | `28px` |
| `--mmpg-card-bg` | `#ffffff` |
| `--mmpg-card-border-color` | `#e6e6ef` |
| `--mmpg-card-border-width` | `1px` |
| `--mmpg-card-border-hover` | `var(--mmpg-accent-color)` |
| `--mmpg-card-radius` | `14px` |
| `--mmpg-card-padding` | `22px` |
| `--mmpg-card-shadow` | `0 1px 2px rgba(24, 24, 43, 0.04)` |
| `--mmpg-card-shadow-hover` | `0 12px 28px -12px rgba(24, 24, 43, 0.22)` |
| `--mmpg-content-gap` | `10px` |
| `--mmpg-image-ratio` | `16 / 10` |
| `--mmpg-image-radius` | `0px` |
| `--mmpg-media-width` | `40%` |
| `--mmpg-overlay-opacity` | `0.7` |
| `--mmpg-title-color` | `#17172b` |
| `--mmpg-title-hover-color` | `#4f46e5` |
| `--mmpg-title-size` | `19px` |
| `--mmpg-title-weight` | `600` |
| `--mmpg-title-line-height` | `1.35` |
| `--mmpg-title-lines` | `0` |
| `--mmpg-meta-color` | `#6b6b80` |
| `--mmpg-meta-size` | `13px` |
| `--mmpg-excerpt-color` | `#4a4a5f` |
| `--mmpg-excerpt-size` | `15px` |
| `--mmpg-excerpt-line-height` | `1.6` |
| `--mmpg-excerpt-lines` | `0` |
| `--mmpg-accent-color` | `#4f46e5` |
| `--mmpg-term-color` | `var(--mmpg-accent-color)` |
| `--mmpg-button-bg` | `#4f46e5` |
| `--mmpg-button-bg-hover` | `#4338ca` |
| `--mmpg-button-color` | `#ffffff` |
| `--mmpg-button-radius` | `8px` |
| `--mmpg-button-padding` | `10px 18px` |

Font family is intentionally absent. The grid inherits the theme typeface and only controls size, weight and line height, which is the main reason a grid usually looks foreign on someone else's site.

Settings stored as percentages print as plain numbers: a title line height of `135` becomes `--mmpg-title-line-height: 1.35`, an overlay strength of `70` becomes `--mmpg-overlay-opacity: 0.7`. Optional colours left empty print nothing, so `--mmpg-term-color` and `--mmpg-card-border-hover` fall back to the accent colour, while an empty card background prints `transparent`. `Assets::properties()` holds the full map and `Assets::declarations()` builds the output for both the site-wide inline CSS and the per grid `style` attribute.

To restyle a single grid, give it a class and target that:

```
[mm_post_grid class="my-compact-grid"]
```

```css
.mmpg.my-compact-grid {
	--mmpg-gap: 14px;
	--mmpg-card-radius: 4px;
}
```

## Modifier classes

Choice settings switch one predictable class on the wrapper, and each of them can be overridden per grid through the shortcode attribute of the same name:

| Setting and attribute | Classes |
| --- | --- |
| `card_layout` | `.mmpg--layout-stacked` `-horizontal` `-overlay` |
| `hover_effect` | `.mmpg--hover-lift` `-shadow` `-border` `-none` |
| `image_hover` | `.mmpg--image-zoom` `-none` `-grayscale` |
| `text_align` | `.mmpg--align-left` `-center` |
| `term_style` | `.mmpg--terms-text` `-badge` |
| `button_style` | `.mmpg--more-link` `-button` `-outline` |
| `button_size` | `.mmpg--button-small` `-medium` `-large` |
| `pagination_align` | `.mmpg--pagination-left` `-center` `-right` |
| `title_lines`, `excerpt_lines` above 0 | `.mmpg--clamp-title`, `.mmpg--clamp-excerpt` |

Colours and sizes are site-wide by design; per grid they are the `class` attribute plus custom properties.

### Layout notes

- **Horizontal** uses a container query on the card, so the image only moves beside the text once the card itself is wider than 460px. In a three column grid the cards stay stacked, and browsers without container queries keep the stacked layout. This is why the card markup has a `.mmpg-card-inner` wrapper: a container cannot restyle itself, only its descendants.
- **Overlay** stacks image and text in one grid cell, so the card grows when the text is taller than the image instead of cutting it off. Text colours are fixed to light values in this layout, because the colour settings are written for a card background. The card has a dark base colour so light text never lands on a light background, even for posts without an image. Clicks on the text fall through to the image link, so the whole card is clickable while links inside the text still work.
- **Line clamping** sits on the title link rather than the heading. An element never clips its own focus outline, so the keyboard focus ring stays fully visible on clamped titles.
- **Outside the cards**, pagination, load more, the empty state and error text inherit your theme text colour and use the button colours, never card colours. A dark card template therefore cannot make them unreadable on a light page.

## Markup reference

```html
<section id="mmpg-1" class="mmpg mmpg--cols-3 mmpg--layout-stacked mmpg--hover-lift mmpg--align-left mmpg--image-zoom mmpg--terms-text mmpg--more-link mmpg--button-medium mmpg--pagination-center">
	<div class="mmpg-grid">
		<article class="mmpg-card">
			<div class="mmpg-card-inner">
				<div class="mmpg-media">
					<a class="mmpg-media-link" href="…" tabindex="-1" aria-hidden="true">
						<img class="mmpg-image" … />
					</a>
				</div>
				<div class="mmpg-body">
					<p class="mmpg-terms"><a class="mmpg-term" href="…">News</a></p>
					<h3 class="mmpg-title"><a class="mmpg-title-link" href="…">Title</a></h3>
					<p class="mmpg-meta">
						<time class="mmpg-date" datetime="…">1 March 2026</time>
						<span class="mmpg-author">By Jane</span>
					</p>
					<div class="mmpg-excerpt">…</div>
					<a class="mmpg-more" href="…">Read more <span class="mmpg-sr-only">about Title</span></a>
				</div>
			</div>
		</article>
	</div>
	<nav class="mmpg-pagination" aria-label="Post grid pagination">…</nav>
</section>
```

### Accessibility decisions behind that markup

- The image link carries `tabindex="-1" aria-hidden="true"` when a title link exists, so each card exposes one link to the same destination instead of three. When titles are hidden, the image link gets a visually hidden title instead.
- Read more links get a hidden `about %s` suffix, so a screen reader user hearing a list of links gets a unique name for each.
- `title_tag` is a setting rather than a hard-coded `h3`, because the right level depends on the page outline.
- Pagination is hand built rather than `paginate_links()`, both to avoid theme `.page-numbers` styles leaking in and to control `aria-current="page"` and the visually hidden "Page" prefixes.
- The plugin ships `.mmpg-sr-only` rather than relying on the theme defining `.screen-reader-text`.
- Load More writes into a `role="status"` region, and moves focus into the first new card only once the button is gone.

The grid uses `section > div > article`. Core's Query Loop uses `ul > li`. Both are defensible; if you want a list, `mmpg_grid_html` and `mmpg_card_html` let you swap the tags, and `.mmpg-grid` already carries `list-style: none`.

## Recipes

**Add a custom shortcode attribute**

```php
add_filter( 'mmpg_shortcode_defaults', function ( $defaults ) {
	$defaults['featured_only'] = 'no';
	return $defaults;
} );

add_filter( 'mmpg_query_args', function ( $query_args, $args ) {
	if ( ! empty( $args['featured_only'] ) && 'no' !== $args['featured_only'] ) {
		$query_args['meta_key']   = '_is_featured';
		$query_args['meta_value'] = '1';
	}
	return $query_args;
}, 10, 2 );
```

**Show reading time under the title**

```php
add_filter( 'mmpg_card_meta', function ( $html, $post ) {
	$words   = str_word_count( wp_strip_all_tags( $post->post_content ) );
	$minutes = max( 1, (int) ceil( $words / 200 ) );

	return $html . sprintf(
		'<p class="mmpg-meta my-reading-time">%s</p>',
		esc_html( sprintf( '%d min read', $minutes ) )
	);
}, 10, 2 );
```

**Render a grid in a template**

```php
echo do_shortcode( '[mm_post_grid columns="2" posts_per_page="4"]' );
```

**Use the renderer directly**

```php
$args  = MM_Post_Grid\Args::from_shortcode( array( 'columns' => '4' ) );
$query = MM_Post_Grid\Query_Builder::query( $args, 1 );

MM_Post_Grid\Assets::enqueue();
echo wp_kses( MM_Post_Grid\Renderer::cards( $query, $args ), MM_Post_Grid\Renderer::allowed_html() );
```

## Coding standards

```bash
composer global require squizlabs/php_codesniffer wp-coding-standards/wpcs
phpcs --standard=phpcs.xml.dist .
```

At 0.1 this reports **0 errors and 0 warnings** across all 14 PHP files, using PHPCS 3.13.2 with WPCS 3.1.0. Both PHPCS rulesets shipped with the Plugin Check plugin (`plugin-check.ruleset.xml` and `plugin-review.xml`) report nothing either.

`phpcs.xml.dist` is a development file and is **not part of the release zip**. Plugin Check rejects every `.dist` file as an application file, so the ruleset lives in the development repository only; copy it into the plugin folder to run the command above.

### Translations

The plugin does not call `load_plugin_textdomain()`. For plugins hosted on WordPress.org, core loads translations from translate.wordpress.org automatically, just in time since WordPress 6.7, and Plugin Check flags the manual call as discouraged. The `Text Domain` header matches the slug, and `languages/mm-post-grid.pot` stays in the package as the source template.

Two annotated exceptions exist, both documented inline where they appear:

- `WordPress.Files.FileName` is excluded in the ruleset for PSR-4 naming.
- One `WordPress.DB.SlowDBQuery.slow_db_query_tax_query` is ignored in `Query_Builder`, since taxonomy filtering is the documented feature and only runs when an author asked for it.

## Roadmap

Deliberately out of scope for 0.1:

- **Gutenberg block.** `Renderer::grid()` already takes a plain array, so the block is a `block.json` plus a `render_callback` that reuses `Args` and `Renderer` unchanged.
- **Carousel and masonry layouts.** Both would mean shipping a layout library, which is at odds with the size goal.
- **A live preview on the Styling tab.** The CSS map in `Assets::properties()` is already shared, so the admin script can set the same custom properties on sample cards as values change.
- **Per grid templates.** A `template` attribute would need the template's full set of custom properties per instance; today one grid can switch layout and modifiers, while colours stay site-wide.
- **Caching the rendered grid.** Object caching around the query is a reasonable 1.1 addition, but it needs careful invalidation and should not be rushed.
