Can’t get custom rewrite tag, query var, permastruct (permalink structure), and rewrite rule to work properly together

Question

Problem specification

I’m implementing a website that is meant to host podcast shownotes and transcripts, and so I want custom permalinks and shortlinks for the webpages:

  • Permalinks of the form /podcasts/<episode_number>/<episode_title>, e.g. /podcasts/12/news-for-august
  • Shortlinks of the form /<episode_number> that redirect to the permalink, e.g. /12 redirects to /podcasts/12/news-for-august.

The value of <episode_number> is stored in an ACF field with meta key episode_number; if multiple published podcasts have the same episode number (which obviously shouldn’t happen in practice), then the one with the lowest post ID is served. The value of <episode_title> is just the post slug, since the podcast post’s title holds the actual episode title (e.g. News for August).

Ideally, partial or “incorrect” URLs that unambiguously match the permalink structure should redirect to the corresponding podcast permalink, e.g. the following should redirect to /podcasts/12/news-for-august:

  • /podcasts/12
  • /podcasts/12/incorrect-title

As a (perhaps important?) sidenote, I’m using Nginx, not Apache — I see a lot of mention of .htaccess modification, which obviously doesn’t apply here, so just mentioning that.

Progress so far

I’ve read up on the Rewrite API, and here’s where I’ve got to after a couple days trying to understand the relevant inner workings…

When CPT UI registers the custom post type podcast, it also adds a permastruct with the name podcast. I can adjust the CPT UI settings for the podcast post type to set the permastruct to /podcasts/<episode_title> — my default permalink for posts is /articles/%post_id%/%postname%, so the default permalink for the custom post type is /articles/podcast/%postname%. I achieve the mentioned change by disabling “With front” (so that the leading /articles is dropped) and setting “Rewrite slug” to podcasts (plural) rather than the default podcast (singular). If I can’t achieve what I want, I’ll probably have to settle for this.

I define a rewrite tag, %podcast_episode_number%, so that I can define my custom permastruct for podcasts. I just override the permastruct with the name podcast that CPT UI added, so it automatically applies to podcasts. I also define a rewrite rule to handle the shortlinks. Here is the relevant section from my theme’s functions.php:

function wpse_add_tag_and_permastruct() {
    /** Define the tag */
    add_rewrite_tag( '%podcast_episode_number%', '([0-9]+)' );

    /** Override the default permalink for the podcast post type */
    add_permastruct(
        'podcast',
        'podcasts/%podcast_episode_number%/%postname%',
        [ 'with_front' => false ]
    );

    /** Define podcast shortlinks */
    add_rewrite_rule( '^([0-9]+)/?', [ 'podcast_episode_number' => '$matches[1]' ], 'top' );
}
add_action( 'init', 'wpse_add_tag_and_permastruct' );

I then define how %podcast_episode_number% should be populated in permalinks by hooking into the post_link and post_type_link filters. Strangely, in the context of podcast permalinks, the %postname% tag isn’t being populated like it is for regular blog posts, so I do that here as well:

function wpse_handle_tag_substitution( $permalink, $post ) {
    // Do nothing if the tag isn't present
    if ( strpos( $permalink, '%podcast_episode_number%' ) === false ) {
        return $permalink;
    }
    
    $fallback = '_';
    
    $episode_number = '';
    if ( function_exists( 'get_field' ) && $post->post_type === 'podcast' ) {
        $episode_number = get_field( 'episode_number', $post->ID, true );
    }
    if ( ! $episode_number ) {
        $episode_number = $fallback;
    }

    $permalink = str_replace( '%podcast_episode_number%', $episode_number, $permalink );
    $permalink = str_replace( '%postname%', $post->post_name, $permalink ); // Strangely, this is needed.

    return $permalink;
}
/**
 * Filter permalinks using `handle_tag_substitution()`. Late priority (100) is
 * assigned so that this filter is called last, when the tags are present.
 */
add_filter( 'post_link', 'wpse_handle_tag_substitution', 100, 2 );
add_filter( 'post_type_link', 'wpse_handle_tag_substitution', 100, 2 );

Finally, I define how the query variable podcast_episode_number (which corresponds to the tag %podcast_episode_number%, and was implicitly created when add_rewrite_tag() was called) should be handled, so that when we visit one of the URLs as describe in our problem specification, WordPress can obtain the corresponding post ID from the podcast_episode_number parameter, and thus serve the post. We hook into the request filter to do this.

function wpse_handle_query_var( $query_vars ) {
    /** Ignore requests that don't concern us. */
    if ( ! isset( $query_vars['podcast_episode_number'] ) ) {
        return $query_vars;
    }

    /** Validate the episode number; it must be a positive integer. */
    if ( preg_match( '/^[0-9]+$/', $query_vars['podcast_episode_number'] ) !== 1 ) {
        /**
         * The episode number is invalid; respond with a 404 Not Found.
         * We do this by requesting the post that has ID -1,
         * which is guaranteed to not exist.
         */
        return [ 'p' => '-1' ];
    }

    /** Casting to `int` removes leading zeroes from the SQL query */
    $episode_number = (int)( $query_vars['podcast_episode_number'] );

    /** Determine the ID of the post with the given episode number. */
    global $wpdb;

    $post_ids = $wpdb->get_col(
        $wpdb->prepare(
            "SELECT post_id FROM {$wpdb->postmeta} WHERE
                    meta_key = 'episode_number'
                AND meta_value = %d
            ORDER BY post_id ASC",
            
            $episode_number
        )
    );

    /**
     * String representing `$post_ids` in SQL syntax,
     * e.g. "('12','14','15','18')".
     */
    $sql_post_ids = "('" . implode( "','", $post_ids ) . "')";

    $post_ids = $wpdb->get_col(
        "SELECT id FROM {$wpdb->posts} WHERE
                id IN {$sql_post_ids}
            AND post_type = 'podcast'
            AND post_status = 'publish'
        ORDER BY id ASC"
    );

    if ( count( $post_ids ) === 0 ) {
        /**
         * There are no published podcasts with the given episode number;
         * respond with 404.
         */
        return [ 'p' => '-1' ];
    }

    /**
     * Request the post with the lowest post ID among published
     * podcasts with the given episode number.
     */
    return [ 'p' => $post_ids[0] ];
}
/**
 * Filter queries using `transform_podcast_episode_query()`.
 * Late priority (100) is assigned to ensure that this filter is applied last.
 */
add_filter( 'request', 'wpse_handle_query_var', 100 );

After all that, and flushing the rewrite rules via [Settings > Permalinks > Save Settings], the link structures work! That is, for example, the web server responds to requests for all of the following URLs with a 301 redirect to /podcasts/12/news-for-august:

  • /12
  • /podcasts/12
  • /podcasts/12/incorrect-title

However, the page itself (/podcasts/12/news-for-august) cannot be found by WordPress… WordPress serves my theme’s 404 template (404.php) and HTTP response is 404, just like any other Not Found URL. Clearly, this is because WordPress doesn’t know what template to use. I can resolve this by returning the post type as well as the post ID in wpse_handle_query_var() (i.e. return [ 'p' => $post_ids[0], 'post_type' => 'podcast' ]), but this has the undesirable effect of making all of the alias URLs listed above also just serve the content rather the redirecting to the permalink — this is obviously horrendous for SEO.

Question

So what gives? How can I get the correct template to load when the client visits a podcast permalink, without other URLs serving the same content? Perhaps hook into template_redirect or use wp_redirect() or something else? Maybe my overall approach here is wrong and someone can point me in the right direction?

Any advice is much appreciated.

0
, , , , Jivan Pal 3 months 0 Answers 44 views 0

Leave an answer