| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Compiles a WordPress permalink structure string (e.g. "/%year%/%postname%/") |
| 9 |
* into a matching regex plus its token list, and normalizes raw structure |
| 10 |
* strings into canonical path form. |
| 11 |
* |
| 12 |
* Pure and stateless: given the same structure string it always returns the |
| 13 |
* same result, with no WordPress or database dependency. Extracted from |
| 14 |
* ABJ_404_Solution_OldPermalinkStructureResolver because structure-to-regex |
| 15 |
* compilation is a distinct, independently testable concern from resolving a |
| 16 |
* matched request to a post. |
| 17 |
*/ |
| 18 |
class ABJ_404_Solution_PermalinkStructureCompiler { |
| 19 |
|
| 20 |
/** |
| 21 |
* @param string $structure |
| 22 |
* @return array{regex: string, tokens: array<int, string>}|null |
| 23 |
*/ |
| 24 |
public function compile(string $structure): ?array { |
| 25 |
$structure = $this->normalizeStructure($structure); |
| 26 |
if ($structure === '') { |
| 27 |
return null; |
| 28 |
} |
| 29 |
|
| 30 |
preg_match_all('/%[a-z_]+%/', $structure, $tokenMatches); |
| 31 |
$tokens = $tokenMatches[0]; |
| 32 |
if (!in_array('%post_id%', $tokens, true) |
| 33 |
&& !in_array('%postname%', $tokens, true) |
| 34 |
&& !in_array('%pagename%', $tokens, true)) { |
| 35 |
return null; |
| 36 |
} |
| 37 |
|
| 38 |
$seenNames = array(); |
| 39 |
foreach ($tokens as $token) { |
| 40 |
$name = trim($token, '%'); |
| 41 |
if (!isset($this->tokenRegexMap()[$token]) || isset($seenNames[$name])) { |
| 42 |
return null; |
| 43 |
} |
| 44 |
$seenNames[$name] = true; |
| 45 |
} |
| 46 |
|
| 47 |
$segments = explode('/', trim($structure, '/')); |
| 48 |
$compiledSegments = array(); |
| 49 |
foreach ($segments as $segment) { |
| 50 |
preg_match_all('/%[a-z_]+%/', $segment, $segmentTokens); |
| 51 |
if (count($segmentTokens[0]) > 1) { |
| 52 |
return null; |
| 53 |
} |
| 54 |
$compiledSegments[] = $this->compileSegment($segment); |
| 55 |
} |
| 56 |
|
| 57 |
return array( |
| 58 |
'regex' => '~^/' . implode('/', $compiledSegments) . '/?$~', |
| 59 |
'tokens' => $tokens, |
| 60 |
); |
| 61 |
} |
| 62 |
|
| 63 |
/** |
| 64 |
* @param string $structure |
| 65 |
* @return string |
| 66 |
*/ |
| 67 |
public function normalizeStructure(string $structure): string { |
| 68 |
$path = parse_url(trim($structure), PHP_URL_PATH); |
| 69 |
$structure = is_string($path) && $path !== '' ? $path : trim($structure); |
| 70 |
if ($structure === '') { |
| 71 |
return ''; |
| 72 |
} |
| 73 |
return $structure[0] === '/' ? $structure : '/' . $structure; |
| 74 |
} |
| 75 |
|
| 76 |
/** |
| 77 |
* @return array<string, string> |
| 78 |
*/ |
| 79 |
private function tokenRegexMap(): array { |
| 80 |
return array( |
| 81 |
'%year%' => '(?P<year>[0-9]{4})', |
| 82 |
'%monthnum%' => '(?P<monthnum>0[1-9]|1[0-2])', |
| 83 |
'%day%' => '(?P<day>0[1-9]|[12][0-9]|3[01])', |
| 84 |
'%hour%' => '(?P<hour>[01][0-9]|2[0-3])', |
| 85 |
'%minute%' => '(?P<minute>[0-5][0-9])', |
| 86 |
'%second%' => '(?P<second>[0-5][0-9])', |
| 87 |
'%post_id%' => '(?P<post_id>[0-9]+)', |
| 88 |
'%postname%' => '(?P<postname>[^/]+)', |
| 89 |
'%pagename%' => '(?P<pagename>[^/]+)', |
| 90 |
'%category%' => '(?P<category>[^/]+(?:/[^/]+)*)', |
| 91 |
'%author%' => '(?P<author>[^/]+)', |
| 92 |
); |
| 93 |
} |
| 94 |
|
| 95 |
/** @param string $segment @return string */ |
| 96 |
private function compileSegment(string $segment): string { |
| 97 |
$map = $this->tokenRegexMap(); |
| 98 |
preg_match('/%[a-z_]+%/', $segment, $match, PREG_OFFSET_CAPTURE); |
| 99 |
if (empty($match)) { |
| 100 |
return preg_quote($segment, '~'); |
| 101 |
} |
| 102 |
|
| 103 |
$token = $match[0][0]; |
| 104 |
$offset = (int)$match[0][1]; |
| 105 |
$before = substr($segment, 0, $offset); |
| 106 |
$after = substr($segment, $offset + strlen($token)); |
| 107 |
return preg_quote($before, '~') . $map[$token] . preg_quote($after, '~'); |
| 108 |
} |
| 109 |
} |
| 110 |
|