Skip to content

Commit b1ab249

Browse files
author
Alex
committed
fix: correct hierarchical tags rewrite rules for WP_Query compatibility & remove duplicate includes
1 parent 4997b2e commit b1ab249

3 files changed

Lines changed: 280 additions & 1 deletion

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
<?php
2+
/**
3+
* Hierarchical Tags Rewrite Rules
4+
* Description: Adds proper rewrite rules for hierarchical tags to support nested URLs like /tag/git/submodules/
5+
* Version: 1.0.0
6+
*/
7+
8+
class HierarchicalTagsRewrite
9+
{
10+
/**
11+
* Class constructor.
12+
*/
13+
public function __construct()
14+
{
15+
add_filter('post_tag_rewrite_rules', [$this, 'tag_rewrite_rules'], 10, 1);
16+
add_filter('term_link', [$this, 'hierarchical_tag_link'], 10, 3);
17+
18+
add_action('created_post_tag', [$this, 'schedule_flush']);
19+
add_action('edited_post_tag', [$this, 'schedule_flush']);
20+
add_action('delete_post_tag', [$this, 'schedule_flush']);
21+
22+
add_action('init', [$this, 'flush'], PHP_INT_MAX);
23+
}
24+
25+
/**
26+
* Generate hierarchical tag link.
27+
*
28+
* @param string $termlink Term link URL.
29+
* @param WP_Term $term Term object.
30+
* @param string $taxonomy Taxonomy slug.
31+
* @return string Modified term link.
32+
*/
33+
public function hierarchical_tag_link($termlink, $term, $taxonomy)
34+
{
35+
if ($taxonomy !== 'post_tag' || !$term->parent) {
36+
return $termlink;
37+
}
38+
39+
$tag_base = get_option('tag_base');
40+
if (empty($tag_base)) {
41+
$tag_base = 'tag';
42+
}
43+
44+
$slug = $term->slug;
45+
if ($term->parent) {
46+
$parents = $this->get_tag_parents($term->parent, false, '/', true);
47+
if (!is_wp_error($parents)) {
48+
$slug = $parents . $slug;
49+
}
50+
}
51+
52+
return home_url(user_trailingslashit($tag_base . '/' . $slug, 'category'));
53+
}
54+
55+
/**
56+
* Save an option that triggers a flush on the next init.
57+
*/
58+
public function schedule_flush()
59+
{
60+
update_option('flush_rewrite_tags', 1);
61+
}
62+
63+
/**
64+
* If the flush option is set, flush the rewrite rules.
65+
*
66+
* @return bool
67+
*/
68+
public function flush()
69+
{
70+
if (get_option('flush_rewrite_tags')) {
71+
add_action('shutdown', 'flush_rewrite_rules');
72+
delete_option('flush_rewrite_tags');
73+
return true;
74+
}
75+
76+
return false;
77+
}
78+
79+
/**
80+
* Generate rewrite rules for hierarchical tags.
81+
*
82+
* @param array $tag_rewrite Existing tag rewrite rules.
83+
* @return array Modified tag rewrite rules.
84+
*/
85+
public function tag_rewrite_rules($tag_rewrite)
86+
{
87+
global $wp_rewrite;
88+
89+
$new_tag_rewrite = [];
90+
91+
$taxonomy = get_taxonomy('post_tag');
92+
if (!$taxonomy || !isset($taxonomy->rewrite['hierarchical']) || !$taxonomy->rewrite['hierarchical']) {
93+
return $tag_rewrite;
94+
}
95+
96+
$tag_base = get_option('tag_base');
97+
if (empty($tag_base)) {
98+
$tag_base = 'tag';
99+
}
100+
101+
$tag_base = trim($tag_base, '/');
102+
103+
$tags = get_terms([
104+
'taxonomy' => 'post_tag',
105+
'hide_empty' => false,
106+
]);
107+
108+
if (is_array($tags) && !empty($tags)) {
109+
foreach ($tags as $tag) {
110+
$tag_nicename = $tag->slug;
111+
112+
if ($tag->parent === $tag->term_id) {
113+
$tag->parent = 0;
114+
} elseif ($tag->parent !== 0) {
115+
$parents = $this->get_tag_parents($tag->parent, false, '/', true);
116+
if (!is_wp_error($parents)) {
117+
$tag_nicename = $parents . $tag_nicename;
118+
}
119+
unset($parents);
120+
}
121+
122+
$new_tag_rewrite = $this->add_tag_rewrites(
123+
$new_tag_rewrite,
124+
$tag_nicename,
125+
$tag_base,
126+
$wp_rewrite->pagination_base
127+
);
128+
129+
$tag_nicename_filtered = $this->convert_encoded_to_upper($tag_nicename);
130+
if ($tag_nicename_filtered !== $tag_nicename) {
131+
$new_tag_rewrite = $this->add_tag_rewrites(
132+
$new_tag_rewrite,
133+
$tag_nicename_filtered,
134+
$tag_base,
135+
$wp_rewrite->pagination_base
136+
);
137+
}
138+
}
139+
unset($tags, $tag, $tag_nicename, $tag_nicename_filtered);
140+
}
141+
142+
// Merge new rules BEFORE default rules so they take precedence
143+
return array_merge($new_tag_rewrite, $tag_rewrite);
144+
}
145+
146+
/**
147+
* Get tag parents path.
148+
*
149+
* @param int $id Tag ID.
150+
* @param bool $link Whether to format with link.
151+
* @param string $separator Path separator.
152+
* @param bool $nicename Whether to use nice name for display.
153+
* @return string|WP_Error Tag parents path or WP_Error on failure.
154+
*/
155+
protected function get_tag_parents($id, $link = false, $separator = '/', $nicename = false)
156+
{
157+
$chain = '';
158+
$parent = get_term($id, 'post_tag');
159+
160+
if (is_wp_error($parent)) {
161+
return $parent;
162+
}
163+
164+
if ($nicename) {
165+
$name = $parent->slug;
166+
} else {
167+
$name = $parent->name;
168+
}
169+
170+
if ($parent->parent && ($parent->parent !== $parent->term_id)) {
171+
$chain .= $this->get_tag_parents($parent->parent, $link, $separator, $nicename);
172+
}
173+
174+
if ($link) {
175+
$chain .= '<a href="' . esc_url(get_term_link($parent)) . '">' . $name . '</a>' . $separator;
176+
} else {
177+
$chain .= $name . $separator;
178+
}
179+
180+
return $chain;
181+
}
182+
183+
/**
184+
* Adds required tag rewrite rules.
185+
*
186+
* @param array $rewrites The current set of rules.
187+
* @param string $tag_name Tag nicename (hierarchical path).
188+
* @param string $tag_base Tag base.
189+
* @param string $pagination_base WP_Query pagination base.
190+
* @return array The added set of rules.
191+
*/
192+
protected function add_tag_rewrites($rewrites, $tag_name, $tag_base, $pagination_base)
193+
{
194+
$rewrite_name = $tag_base . '/(' . $tag_name . ')';
195+
196+
// Extract the actual slug from the hierarchical path (e.g. 'git/submodules' -> 'submodules')
197+
$parts = explode('/', $tag_name);
198+
$actual_slug = end($parts);
199+
200+
$rewrites[$rewrite_name . '/(?:feed/)?(feed|rdf|rss|rss2|atom)/?$'] = 'index.php?tag=' . $actual_slug . '&feed=$matches[2]';
201+
$rewrites[$rewrite_name . '/' . $pagination_base . '/?([0-9]{1,})/?$'] = 'index.php?tag=' . $actual_slug . '&paged=$matches[2]';
202+
$rewrites[$rewrite_name . '/?$'] = 'index.php?tag=' . $actual_slug;
203+
204+
return $rewrites;
205+
}
206+
207+
/**
208+
* Walks through tag nicename and convert encoded parts into uppercase.
209+
*
210+
* @param string $name The encoded tag URI string.
211+
* @return string The converted URI string.
212+
*/
213+
protected function convert_encoded_to_upper($name)
214+
{
215+
if (strpos($name, '%') === false) {
216+
return $name;
217+
}
218+
219+
$names = explode('/', $name);
220+
$names = array_map([$this, 'encode_to_upper'], $names);
221+
222+
return implode('/', $names);
223+
}
224+
225+
/**
226+
* Converts the encoded URI string to uppercase.
227+
*
228+
* @param string $encoded The encoded string.
229+
* @return string The uppercased string.
230+
*/
231+
public function encode_to_upper($encoded)
232+
{
233+
if (strpos($encoded, '%') === false) {
234+
return $encoded;
235+
}
236+
237+
return strtoupper($encoded);
238+
}
239+
}
240+
241+
function hierarchical_tags_rewrite()
242+
{
243+
static $instance = null;
244+
if ($instance === null) {
245+
$instance = new HierarchicalTagsRewrite();
246+
}
247+
return $instance;
248+
}

src/Config/main.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,13 @@
5151
[
5252
'id' => 'remove_category_url',
5353
'type' => 'switcher',
54-
'title' => __('Enable duplicate post and page', 'wp-addon'),
54+
'title' => __('Remove category base from URL', 'wp-addon'),
55+
'default' => true,
56+
],
57+
[
58+
'id' => 'hierarchical_tags_rewrite',
59+
'type' => 'switcher',
60+
'title' => __('Enable hierarchical tags rewrite rules', 'wp-addon'),
5561
'default' => true,
5662
],
5763
[

src/Core/Plugin.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,31 @@ private function loadModules(): void {
233233
private function addHooks(): void
234234
{
235235
add_action('plugins_loaded', [$this, 'onPluginsLoaded']);
236+
add_action('init', [$this, 'loadSeoFunctions'], 1);
237+
}
238+
239+
/**
240+
* Load SEO functions early
241+
*/
242+
public function loadSeoFunctions(): void
243+
{
244+
$seo_dir = $this->dir . 'functions/seo/';
245+
if (is_dir($seo_dir)) {
246+
foreach (glob($seo_dir . '*.php') as $file) {
247+
require_once $file;
248+
}
249+
}
250+
251+
// Also load from functions/posts, functions/terms, etc
252+
$function_subdirs = ['posts', 'terms', 'comments', 'users', 'shortcodes', 'widgets', 'dashboard-widget', 'cf7', 'vc', 'TinyMCE'];
253+
foreach ($function_subdirs as $subdir) {
254+
$dir = $this->dir . 'functions/' . $subdir . '/';
255+
if (is_dir($dir)) {
256+
foreach (glob($dir . '*.php') as $file) {
257+
require_once $file;
258+
}
259+
}
260+
}
236261
}
237262

238263
/**

0 commit comments

Comments
 (0)