PluginProbe
CryptX / trunk
CryptX vtrunk
4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 All 92 releases
cryptx / classes / Admin / Changelog.php

Changelog.php in CryptX trunk, at classes/Admin/Changelog.php

141 lines 4.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX\Admin;
4
5 /**
6 * Reads the changelog out of readme.txt.
7 *
8 * readme.txt is the single source: it is what wordpress.org publishes, so a
9 * second copy kept for the admin screen would inevitably fall behind.
10 *
11 * @package CryptX
12 * @since 4.1.0
13 */
14 final class Changelog
15 {
16 /**
17 * How many releases the settings screen shows. The full history lives in
18 * readme.txt and on wordpress.org; showing all of it would bury the recent
19 * entries, which are the ones anyone actually reads.
20 */
21 private const MAX_ENTRIES = 12;
22
23 /**
24 * The most recent releases.
25 *
26 * @return array<int, array{version: string, items: array<int, string>}>
27 */
28 public static function recent(): array
29 {
30 $path = CRYPTX_DIR_PATH . 'readme.txt';
31
32 if (!is_readable($path)) {
33 return [];
34 }
35
36 $contents = file_get_contents($path);
37
38 if ($contents === false) {
39 return [];
40 }
41
42 return array_slice(self::parse($contents), 0, self::MAX_ENTRIES);
43 }
44
45 /**
46 * Pulls the changelog section apart into releases and their entries.
47 *
48 * @param string $contents The readme contents.
49 *
50 * @return array<int, array{version: string, items: array<int, string>}>
51 */
52 private static function parse(string $contents): array
53 {
54 $contents = str_replace(["\r\n", "\r"], "\n", $contents);
55
56 if (!preg_match('/^==\s*Changelog\s*==\s*$(.*?)(?=^==\s|\z)/ms', $contents, $section)) {
57 return [];
58 }
59
60 // preg_match_all rather than preg_split with DELIM_CAPTURE: the split
61 // put whatever stood before the first "= version =" -- here the newline
62 // after the section heading -- into element zero. It is not empty, so
63 // PREG_SPLIT_NO_EMPTY kept it, and every version was paired with the
64 // previous release's entries. Matching version and body together makes
65 // that class of off-by-one impossible.
66 if (!preg_match_all('/^=\s*(.+?)\s*=\s*$\n(.*?)(?=^=\s|\z)/ms', $section[1], $matches, PREG_SET_ORDER)) {
67 return [];
68 }
69
70 $releases = [];
71
72 foreach ($matches as $match) {
73 $version = trim($match[1]);
74 $items = [];
75
76 foreach (explode("\n", $match[2]) as $line) {
77 $line = trim($line);
78
79 if ($line === '') {
80 continue;
81 }
82
83 if (str_starts_with($line, '* ')) {
84 $line = substr($line, 2);
85 }
86
87 $items[] = self::formatEntry($line);
88 }
89
90 if ($version !== '' && $items !== []) {
91 $releases[] = [
92 'version' => $version,
93 'items' => $items,
94 ];
95 }
96 }
97
98 return $releases;
99 }
100
101 /**
102 * Turns one changelog line into the markup the screen shows.
103 *
104 * readme.txt is written in the markup wordpress.org understands, which is
105 * a small subset of Markdown mixed with plain HTML. Passing it through
106 * untouched left literal asterisks on screen -- "* **Security** fixed..."
107 * instead of a bold word.
108 *
109 * @param string $line One entry, without its leading bullet.
110 *
111 * @return string Safe markup.
112 */
113 private static function formatEntry(string $line): string
114 {
115 // Escape first, then reintroduce exactly the markup we mean. Doing it
116 // the other way round would let a stray "<" from the readme through.
117 $line = esc_html($line);
118
119 $line = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $line) ?? $line;
120 $line = preg_replace('/`(.+?)`/s', '<code>$1</code>', $line) ?? $line;
121
122 // The older entries carry real links to the support forum. They were
123 // escaped above, so bring back just the anchor.
124 $line = preg_replace(
125 '/&lt;a href=&quot;(https?:\/\/[^&]+)&quot;&gt;(.*?)&lt;\/a&gt;/i',
126 '<a href="$1" target="_blank" rel="noopener noreferrer">$2</a>',
127 $line
128 ) ?? $line;
129
130 return wp_kses(
131 $line,
132 [
133 'strong' => [],
134 'em' => [],
135 'code' => [],
136 'a' => ['href' => [], 'target' => [], 'rel' => []],
137 ]
138 );
139 }
140 }
141