PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / matching / PermalinkStructureCompiler.php

PermalinkStructureCompiler.php in 404 Solution trunk, at includes/matching/PermalinkStructureCompiler.php

110 lines 3.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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