DEV3 Technologies home

Changelog post type with version meta box

A release-notes post type with a nonce-protected version field, admin columns and a shortcode that lists releases newest first.

WordPressDatabasechangelogmeta-boxcptshortcode

Code

<?php
/**
 * Changelog entries with a version number.
 *
 * @package Acme_Theme
 */

defined( 'ABSPATH' ) || exit;

add_action( 'init', static function (): void {
	register_post_type( 'acme_release', array(
		'label'        => __( 'Releases', 'acme-theme' ),
		'public'       => true,
		'menu_icon'    => 'dashicons-megaphone',
		'supports'     => array( 'title', 'editor' ),
		'has_archive'  => 'changelog',
		'rewrite'      => array( 'slug' => 'changelog' ),
		'show_in_rest' => true,
	) );
} );

// --- Meta box -------------------------------------------------------------
add_action( 'add_meta_boxes', static function (): void {
	add_meta_box( 'acme_release_box', __( 'Release details', 'acme-theme' ), 'acme_release_box', 'acme_release', 'side' );
} );

function acme_release_box( WP_Post $post ): void {
	wp_nonce_field( 'acme_release_save', 'acme_release_nonce' );
	$version = get_post_meta( $post->ID, '_acme_version', true );
	printf(
		'<label for="acme_version">%s</label><input type="text" id="acme_version" name="acme_version" value="%s" class="widefat" placeholder="1.4.0">',
		esc_html__( 'Version', 'acme-theme' ),
		esc_attr( (string) $version )
	);
}

add_action( 'save_post_acme_release', static function ( int $post_id ): void {
	if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
		return;
	}
	$nonce = isset( $_POST['acme_release_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['acme_release_nonce'] ) ) : '';
	if ( ! wp_verify_nonce( $nonce, 'acme_release_save' ) || ! current_user_can( 'edit_post', $post_id ) ) {
		return;
	}
	$version = sanitize_text_field( wp_unslash( $_POST['acme_version'] ?? '' ) );
	if ( preg_match( '/^\d+\.\d+(\.\d+)?(-[0-9A-Za-z.]+)?$/', $version ) ) {
		update_post_meta( $post_id, '_acme_version', $version );
	} else {
		delete_post_meta( $post_id, '_acme_version' );
	}
} );

// --- Admin column ---------------------------------------------------------
add_filter( 'manage_acme_release_posts_columns', static fn( array $cols ): array => $cols + array( 'version' => __( 'Version', 'acme-theme' ) ) );
add_action( 'manage_acme_release_posts_custom_column', static function ( string $col, int $id ): void {
	if ( 'version' === $col ) {
		echo esc_html( (string) get_post_meta( $id, '_acme_version', true ) );
	}
}, 10, 2 );

// --- [acme_changelog limit="10"] -----------------------------------------
add_shortcode( 'acme_changelog', static function ( $atts ): string {
	$atts  = shortcode_atts( array( 'limit' => 10 ), $atts, 'acme_changelog' );
	$posts = get_posts( array( 'post_type' => 'acme_release', 'numberposts' => min( 50, absint( $atts['limit'] ) ) ) );
	$out   = '';
	foreach ( $posts as $p ) {
		$out .= sprintf(
			'<article class="acme-release"><h3>%1$s <small>%2$s</small></h3><div>%3$s</div></article>',
			esc_html( get_the_title( $p ) ),
			esc_html( (string) get_post_meta( $p->ID, '_acme_version', true ) ),
			apply_filters( 'the_content', $p->post_content )
		);
	}
	return $out;
} );

How it works

What it solves

Release notes deserve structure: a version, a date, and a predictable URL. A dedicated post type keeps them out of the blog, and a validated version field lets themes and scripts rely on it.

How it works

The type is registered with its own archive. A side meta box prints a nonce and a text field. On save_post_acme_release the handler skips autosaves, verifies the nonce and the user's right to edit that post, then accepts the value only if it looks like a semantic version, deleting the meta otherwise. A column filter and action add the version to the list table.

The shortcode fetches up to fifty releases and renders each through the normal content filters.

Adapting it

Add a second meta key for severity or download link and save it in the same handler. To sort by version rather than date, store a zero-padded sortable copy. Use a block-based version of the box with register_post_meta and a sidebar plugin if you want it in the editor. Rename the prefix and slug as needed.

Pitfalls

  • Skipping the nonce or capability check is the usual security hole in meta boxes.
  • The underscore prefix hides the key from the Custom Fields panel.
  • apply_filters( 'the_content' ) inside a shortcode can recurse if the entry contains the same shortcode.
  • Flush permalinks after changing the archive slug.

How to use it

Save as inc/changelog.php and require it from functions.php.

Add a release in the admin and fill in the version.

Place [acme_changelog limit="5"] on a page to list the latest releases.

Licence: GPL-2.0-or-later. This snippet comes from WordPress code and is GPL-2.0-or-later.

Related snippets