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.