showPoweredBy ? "\n\n\n\n" : "\n\n\n";
75 |
76 | if (false === Config::get()->showCopyright) {
77 | return;
78 | }
79 |
80 | if (null === $year) {
81 | $dateRange = $this->postRepository->getPostYearsRange();
82 | $copyrightText = TextUtils::formatCopyrightText($dateRange);
83 | } else {
84 | $copyrightText = sprintf('Copyright (c) %s %s', $year, Config::get()->author->getInformation());
85 | }
86 |
87 | if (Validator::isMobileDevice()) {
88 | $copyrightText = sprintf('(c) %s', Config::get()->author->getEmail());
89 | }
90 |
91 | echo TextUtils::centerText($copyrightText);
92 |
93 | if (Config::get()->showPoweredBy) {
94 | echo "\n\n";
95 | $poweredByText = Validator::isMobileDevice() ?
96 | 'Powered by Weblog' :
97 | 'Powered by Weblog v'.Config::get()->version;
98 | echo TextUtils::centerText($poweredByText);
99 | }
100 |
101 | echo "\n\n\n";
102 | }
103 |
104 | /**
105 | * Sets the content type header.
106 | *
107 | * @param ContentType $contentType enum
108 | */
109 | public function setHeaders(ContentType $contentType): void
110 | {
111 | header(sprintf('Content-Type: %s; charset=utf-8', $contentType->value));
112 | header(sprintf('X-Source-Code: %s', Config::get()->sourceCodeUrl));
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/Controller/FeedController.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Controller;
25 |
26 | use Weblog\Controller\Abstract\AbstractController;
27 | use Weblog\Exception\NotFoundException;
28 | use Weblog\Model\Enum\ContentType;
29 | use Weblog\Utils\FeedGenerator;
30 |
31 | final class FeedController extends AbstractController
32 | {
33 | /**
34 | * Renders the sitemap in XML format, listing all posts, including the main page.
35 | * Sorts posts from newest to oldest.
36 | */
37 | public function renderSitemap(): void
38 | {
39 | $posts = $this->postRepository->fetchAllPosts();
40 | $posts->sort();
41 | $siteMap = FeedGenerator::generateSiteMap($posts);
42 |
43 | $dom = new \DOMDocument('1.0', 'UTF-8');
44 | $dom->preserveWhiteSpace = false;
45 | $dom->formatOutput = true;
46 |
47 | $dom->loadXML($siteMap->asXML());
48 |
49 | $this->setHeaders(ContentType::XML);
50 | echo $dom->saveXML();
51 | }
52 |
53 | /**
54 | * Renders an RSS feed for the Weblog.
55 | */
56 | public function renderRSS(): void
57 | {
58 | $posts = $this->postRepository->fetchAllPosts();
59 | if ($posts->isEmpty()) {
60 | throw new NotFoundException();
61 | }
62 | $rss = FeedGenerator::generateRSS($posts);
63 |
64 | $dom = new \DOMDocument('1.0', 'UTF-8');
65 | $dom->preserveWhiteSpace = false;
66 | $dom->formatOutput = true;
67 |
68 | $dom->loadXML($rss->asXML());
69 |
70 | $this->setHeaders(ContentType::XML);
71 |
72 | echo $dom->saveXML();
73 | }
74 |
75 | /**
76 | * Renders an RSS feed for the given category.
77 | *
78 | * @param string $category the category to filter by
79 | */
80 | public function renderRSSByCategory(string $category): void
81 | {
82 | $posts = $this->postRepository->fetchPostsByCategory($category);
83 | if ($posts->isEmpty()) {
84 | throw new NotFoundException();
85 | }
86 | $rss = FeedGenerator::generateRSS($posts, $category);
87 |
88 | $dom = new \DOMDocument('1.0', 'UTF-8');
89 | $dom->preserveWhiteSpace = false;
90 | $dom->formatOutput = true;
91 |
92 | $dom->loadXML($rss->asXML());
93 |
94 | $this->setHeaders(ContentType::XML);
95 |
96 | echo $dom->saveXML();
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/Controller/PostController.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Controller;
25 |
26 | use Weblog\Config;
27 | use Weblog\Controller\Abstract\AbstractController;
28 | use Weblog\Exception\NotFoundException;
29 | use Weblog\Model\Entity\Post;
30 | use Weblog\Model\Enum\ShowUrls;
31 | use Weblog\Model\PostCollection;
32 | use Weblog\Utils\ContentFormatter;
33 | use Weblog\Utils\StringUtils;
34 | use Weblog\Utils\TextUtils;
35 | use Weblog\Utils\HttpUtils;
36 |
37 | final class PostController extends AbstractController
38 | {
39 | /**
40 | * Renders the home page.
41 | */
42 | public function renderHome(): void
43 | {
44 | echo TextUtils::formatAboutHeader();
45 | echo TextUtils::formatAboutText();
46 |
47 | $this->renderPosts();
48 | $this->renderFooter();
49 | }
50 |
51 | /**
52 | * Displays posts.
53 | *
54 | * @param PostCollection $posts defaults to all
55 | * @param bool $showUrls indicates if we should append URLs to each post
56 | * @param bool $isPostNewline indicates if we should display additional newlines between posts (could be refactored)
57 | */
58 | public function renderPosts(?PostCollection $posts = null, string $showUrls = 'Off', bool $isPostNewline = false): void
59 | {
60 | if (null === $posts) {
61 | $posts = $this->postRepository->fetchAllPosts();
62 | }
63 |
64 | $lastIndex = $posts->count() - 1;
65 | foreach ($posts as $index => $post) {
66 | if (!$post instanceof Post) {
67 | continue;
68 | }
69 |
70 | if ($isPostNewline) {
71 | echo "\n\n\n\n";
72 | }
73 |
74 | $this->renderPost($post, $showUrls);
75 |
76 | if ($index !== $lastIndex && !$isPostNewline) {
77 | echo "\n\n\n\n";
78 | }
79 | }
80 | }
81 |
82 | /**
83 | * Retrieves the requested post based on the GET parameter, converting the title to a slug and handling .txt extension.
84 | *
85 | * @param string $postSlug the post's slug
86 | *
87 | * @return null|Post the post of the requested post or null if not found
88 | */
89 | public function getRequestedPost(string $postSlug): ?Post
90 | {
91 | $postSlug = StringUtils::sanitize($postSlug);
92 |
93 | if (isset(Config::get()->rewrites[rtrim($postSlug, '/')])) {
94 | $redirectUrl = Config::get()->rewrites[$postSlug];
95 | if (str_starts_with($redirectUrl, 'http://') || str_starts_with($redirectUrl, 'https://')) {
96 | HttpUtils::redirect($redirectUrl, 301);
97 | } else {
98 | HttpUtils::redirect(Config::get()->url . '/' . $redirectUrl . '/', 301);
99 | }
100 |
101 | exit;
102 | }
103 |
104 | return $this->postRepository->fetchPostInDirectory($postSlug);
105 | }
106 |
107 | /**
108 | * Renders a single post, including its header, content, and optionally a URL.
109 | *
110 | * @param bool $showUrls indicates if we should append URLs to each post
111 | */
112 | public function renderPost(Post $post, string $showUrls = 'Off'): void
113 | {
114 | $title = ltrim($post->getTitle(), '.');
115 | $category = ltrim($post->getCategory(), '.');
116 | $date = $post->getDate()->format('j F Y');
117 |
118 | $header = ContentFormatter::formatPostHeader($title, $category, $date);
119 |
120 | echo $header."\n\n\n";
121 |
122 | echo ContentFormatter::formatPostContent($post->getContent());
123 |
124 | if ($showUrls && (ShowUrls::FULL === Config::get()->showUrls | ShowUrls::SHORT === Config::get()->showUrls)) {
125 | $url = StringUtils::formatUrl($post->getSlug());
126 | echo "\n ".$url."\n\n";
127 | }
128 | }
129 |
130 | /**
131 | * Renders a single post, to be used in the full post view.
132 | */
133 | public function renderFullPost(Post $post): void
134 | {
135 | echo "\n\n\n\n";
136 | $this->renderPost($post);
137 | $this->renderFooter($post->getDate()->format('Y'));
138 | }
139 |
140 | /**
141 | * Renders a draft post.
142 | *
143 | * @param string $slug The slug of the draft to render.
144 | * @throws NotFoundException If the draft is not found.
145 | */
146 | public function renderDraft(string $slug): void
147 | {
148 | $draft = $this->postRepository->fetchDraftBySlug($slug);
149 | if (null === $draft) {
150 | throw new NotFoundException();
151 | }
152 | $this->renderFullPost($draft);
153 | }
154 |
155 | /**
156 | * Renders posts filtered by category.
157 | *
158 | * @param string $category category name from URL
159 | */
160 | public function renderPostsByCategory(string $category): void
161 | {
162 | $posts = $this->postRepository->fetchPostsByCategory($category);
163 |
164 | if ($posts->isEmpty()) {
165 | throw new NotFoundException();
166 | }
167 |
168 | echo "\n\n\n\n";
169 | $this->renderPosts($posts);
170 | $this->renderFooter($posts->getYearRange());
171 | }
172 |
173 | /**
174 | * Renders posts filtered by date.
175 | *
176 | * @param string $datePath date path from URL in format yyyy/mm/dd or yyyy/mm or yyyy
177 | */
178 | public function renderPostsByDate(string $datePath): void
179 | {
180 | [$date, $precision] = StringUtils::extractDateFromPath($datePath);
181 |
182 | if (null === $date) {
183 | throw new NotFoundException();
184 | }
185 |
186 | $posts = $this->postRepository->fetchPostsByDate($date, $precision);
187 |
188 | if ($posts->isEmpty()) {
189 | throw new NotFoundException();
190 | }
191 | $this->renderPosts($posts, 'Off', true);
192 | $this->renderFooter($date->format('Y'));
193 | }
194 |
195 | /**
196 | * Renders a random post from all available posts.
197 | */
198 | public function renderRandomPost(): void
199 | {
200 | $posts = $this->postRepository->fetchAllPosts();
201 |
202 | if ($posts->isEmpty()) {
203 | throw new NotFoundException();
204 | }
205 |
206 | $randomPost = $posts->getRandomPost();
207 | $this->renderFullPost($randomPost);
208 | }
209 |
210 | /**
211 | * Renders the latest post.
212 | */
213 | public function renderLatestPost(): void
214 | {
215 | $posts = $this->postRepository->fetchAllPosts();
216 | if ($posts->isEmpty()) {
217 | throw new NotFoundException();
218 | }
219 | $latestPost = $posts->getFirstPost();
220 | $this->renderFullPost($latestPost);
221 | }
222 |
223 | /**
224 | * Renders posts from the last year.
225 | */
226 | public function renderLatestYear(): void
227 | {
228 | $startOfYear = new \DateTimeImmutable('first day of January this year 00:00:00');
229 | $today = new \DateTimeImmutable('now');
230 |
231 | $posts = $this->postRepository->fetchPostsFromDateRange($startOfYear, $today);
232 | if ($posts->isEmpty()) {
233 | throw new NotFoundException();
234 | }
235 | $this->renderPosts($posts, 'Off', true);
236 | $this->renderFooter($posts->getYearRange());
237 | }
238 |
239 | /**
240 | * Renders posts from the last month.
241 | */
242 | public function renderLatestMonth(): void
243 | {
244 | $startOfMonth = new \DateTimeImmutable('first day of this month 00:00:00');
245 | $today = new \DateTimeImmutable('now');
246 |
247 | $posts = $this->postRepository->fetchPostsFromDateRange($startOfMonth, $today);
248 | if ($posts->isEmpty()) {
249 | throw new NotFoundException();
250 | }
251 | $this->renderPosts($posts, 'Off', true);
252 | $this->renderFooter($posts->getYearRange());
253 | }
254 |
255 | /**
256 | * Renders posts from the last week.
257 | */
258 | public function renderLatestWeek(): void
259 | {
260 | $today = new \DateTimeImmutable('now');
261 | $startOfWeek = $today->modify('monday this week 00:00:00');
262 | $endOfWeek = $today;
263 |
264 | $posts = $this->postRepository->fetchPostsFromDateRange($startOfWeek, $endOfWeek);
265 | if ($posts->isEmpty()) {
266 | throw new NotFoundException();
267 | }
268 | $this->renderPosts($posts, 'Off', true);
269 | $this->renderFooter($posts->getYearRange());
270 | }
271 |
272 | /**
273 | * Renders posts from the last day.
274 | */
275 | public function renderLatestDay(): void
276 | {
277 | $today = new \DateTimeImmutable('today 00:00:00');
278 | $now = new \DateTimeImmutable('now');
279 |
280 | $posts = $this->postRepository->fetchPostsFromDateRange($today, $now);
281 | if ($posts->isEmpty()) {
282 | throw new NotFoundException();
283 | }
284 | $this->renderPosts($posts, 'Off', true);
285 | $this->renderFooter($posts->getYearRange());
286 | }
287 |
288 | /**
289 | * Renders the selected posts.
290 | *
291 | * Fetches and displays all posts marked as selected. If no selected posts are found, a NotFoundException is thrown.
292 | *
293 | * @throws NotFoundException if no selected posts are found.
294 | */
295 | public function renderSelectedPosts(): void
296 | {
297 | $posts = $this->postRepository->fetchSelectedPosts();
298 | if ($posts->isEmpty()) {
299 | throw new NotFoundException();
300 | }
301 | $this->renderPosts($posts, 'Off', true);
302 | $this->renderFooter($posts->getYearRange());
303 | }
304 |
305 | /**
306 | * Renders search results.
307 | *
308 | * @param string $query the search query
309 | */
310 | public function renderSearchResults(string $query): void
311 | {
312 | $posts = $this->postRepository->searchPosts($query);
313 | if ($posts->isEmpty()) {
314 | throw new NotFoundException();
315 | }
316 | $this->renderPosts($posts, 'Off', true);
317 | $this->renderFooter($posts->getYearRange());
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/src/Controller/Router.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Controller;
25 |
26 | use Weblog\Config;
27 | use Weblog\Model\Route;
28 | use Weblog\Utils\StringUtils;
29 | use Weblog\Utils\Validator;
30 | use Weblog\Utils\Logger;
31 | use Weblog\Utils\HttpUtils;
32 |
33 | final class Router
34 | {
35 | public function __construct(
36 | private readonly PostController $postController,
37 | private readonly FeedController $feedController,
38 | ) {}
39 |
40 | /**
41 | * Handles URIs to perform redirection based on predefined rules.
42 | *
43 | * @param string $uri The requested URI.
44 | *
45 | * @return bool Returns true if a redirection has been made, false otherwise.
46 | */
47 | private function handleRedirectRoute(string $uri): bool
48 | {
49 | $scheme = HttpUtils::getScheme();
50 | $host = HttpUtils::getHost();
51 |
52 | // Do not redirect '/sitemap.xml'
53 | if ($uri === '/sitemap.xml') {
54 | return false;
55 | }
56 |
57 | // Normalize multiple slashes and redirect to a single slash version
58 | if (preg_match('#^([^.]*?\/)\/+(.*)$#', $uri, $matches)) {
59 | $normalizedPath = preg_replace('#/{2,}#', '/', "{$matches[1]}{$matches[2]}");
60 | HttpUtils::redirect("{$scheme}://{$host}{$normalizedPath}");
61 | return true;
62 | }
63 |
64 | // Remove trailing slash in .txt files and correct to full URL
65 | if (preg_match('#^/(.+)\.txt/$#', $uri, $matches)) {
66 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}.txt");
67 | return true;
68 | }
69 |
70 | // Handle .txt extension for routing
71 | if (preg_match('#^/(.+)\.txt$#', $uri, $matches)) {
72 | $_GET['go'] = $matches[1];
73 | return false;
74 | }
75 |
76 | // Ensure names that don't end in slash are redirected with a slash
77 | if (preg_match('#^/([^/]+)$#', $uri, $matches)) {
78 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/");
79 | return true;
80 | }
81 |
82 | // Year paths addition of trailing slash with full URL
83 | if (preg_match('#^/(\d{4})$#', $uri, $matches)) {
84 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/");
85 | return true;
86 | }
87 |
88 | // Ensure year/month paths end with slash with full URL
89 | if (preg_match('#^/(\d{4})/(\d{2})$#', $uri, $matches)) {
90 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/{$matches[2]}/");
91 | return true;
92 | }
93 |
94 | // Ensure year/month/day paths end with slash with full URL
95 | if (preg_match('#^/(\d{4})/(\d{2})/(\d{2})$#', $uri, $matches)) {
96 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/{$matches[2]}/{$matches[3]}/");
97 | return true;
98 | }
99 |
100 | // Ensure RSS category paths are canonical with full URL
101 | if (preg_match('#^/rss/([\w-]+)$#', $uri, $matches)) {
102 | HttpUtils::redirect("{$scheme}://{$host}/rss/{$matches[1]}/");
103 | return true;
104 | }
105 |
106 | // Normalize latest path with trailing slash
107 | if (preg_match('#^/latest$#', $uri)) {
108 | HttpUtils::redirect("{$scheme}://{$host}/latest/");
109 | return true;
110 | }
111 |
112 | // Normalize latest subpaths with trailing slash
113 | if (preg_match('#^/latest/([^/]+)$#', $uri, $matches)) {
114 | HttpUtils::redirect("{$scheme}://{$host}/latest/{$matches[1]}/");
115 | return true;
116 | }
117 |
118 | // Normalize search paths with trailing slash
119 | if (preg_match('#^/search/(.*[^/])$#', $uri, $matches)) {
120 | HttpUtils::redirect("{$scheme}://{$host}/search/{$matches[1]}/");
121 | return true;
122 | }
123 |
124 | // Handle "/search" path setting 'go' GET parameter
125 | if (preg_match('#^/search/(.*)/$#', $uri, $matches)) {
126 | $_GET['go'] = "search/{$matches[1]}";
127 | return false;
128 | }
129 |
130 | // Normalize selected path with trailing slash
131 | if (preg_match('#^/selected$#', $uri)) {
132 | HttpUtils::redirect("{$scheme}://{$host}/selected/");
133 | return true;
134 | }
135 |
136 | // Normalize draft paths with trailing slash
137 | if (preg_match('#^/drafts/([^/]+)$#', $uri, $matches)) {
138 | HttpUtils::redirect("{$scheme}://{$host}/drafts/{$matches[1]}/");
139 | return true;
140 | }
141 |
142 | return false;
143 | }
144 |
145 | /**
146 | * Routes the request based on server parameters using a predefined set of routes.
147 | */
148 | public function route(): void
149 | {
150 | $uri = $_SERVER['REQUEST_URI'];
151 |
152 | if ($this->handleRedirectRoute($uri)) {
153 | return;
154 | }
155 |
156 | if ($this->isFaviconRequest($_SERVER['REQUEST_URI'])) {
157 | $this->handleFaviconRequest();
158 | return;
159 | }
160 |
161 | $routeKey = isset($_GET['go']) && is_string($_GET['go']) ? $this->sanitizeRouteKey($_GET['go']) : null;
162 |
163 | $requestedRoute = $routeKey !== null ? (Route::tryFrom($routeKey) ?? $routeKey) : null;
164 |
165 | if ($routeKey === null) {
166 | $this->postController->renderHome();
167 | $this->logRequest();
168 | return;
169 | }
170 |
171 | try {
172 | match ($requestedRoute) {
173 | Route::SITEMAP => $this->feedController->renderSitemap(),
174 | Route::RSS => $this->feedController->renderRSS(),
175 | Route::RANDOM => $this->postController->renderRandomPost(),
176 | Route::LATEST => $this->postController->renderLatestPost(),
177 | Route::LATEST_YEAR => $this->postController->renderLatestYear(),
178 | Route::LATEST_MONTH => $this->postController->renderLatestMonth(),
179 | Route::LATEST_WEEK => $this->postController->renderLatestWeek(),
180 | Route::LATEST_DAY => $this->postController->renderLatestDay(),
181 | default => $this->handleDynamicRoute($routeKey),
182 | };
183 | $this->logRequest();
184 | } catch (\Exception) {
185 | $this->postController->handleNotFound();
186 | }
187 | }
188 |
189 | /**
190 | * Log the request using Logger.
191 | */
192 | private function logRequest(): void
193 | {
194 | $status = http_response_code();
195 | if (Config::get()->enableLogging && $status == 200) {
196 | Logger::getInstance(Config::get()->logFilePath)->log();
197 | }
198 | }
199 |
200 | /**
201 | * Checks if the request is for favicon.ico
202 | *
203 | * @param string $uri The requested URI
204 | * @return bool
205 | */
206 | private function isFaviconRequest(string $uri): bool
207 | {
208 | return preg_match('~^/[^/]*?/favicon\.ico$~', $uri) || preg_match('~^/favicon\.ico$~', $uri);
209 | }
210 |
211 | /**
212 | * Handles the favicon.ico request by serving the root favicon.ico
213 | */
214 | private function handleFaviconRequest(): void
215 | {
216 | $faviconPath = $_SERVER['DOCUMENT_ROOT'] . '/favicon.ico';
217 |
218 | if (file_exists($faviconPath)) {
219 | header('Content-Type: image/vnd.microsoft.icon');
220 | readfile($faviconPath);
221 | } else {
222 | $this->postController->handleNotFound();
223 | }
224 | }
225 |
226 | /**
227 | * Sanitizes the route key parameter.
228 | *
229 | * @param string $routeKey The route key to sanitize.
230 | * @return string The sanitized route key.
231 | */
232 | private function sanitizeRouteKey(string $routeKey): string
233 | {
234 | $normalized = \Normalizer::normalize($routeKey, \Normalizer::FORM_D);
235 | $sanitized = preg_replace('/[\p{Mn}\p{Me}\p{Cf}]/u', '', $normalized);
236 | return $sanitized;
237 | }
238 |
239 | /**
240 | * Sanitizes a slug parameter.
241 | *
242 | * @param string $slug The slug to sanitize.
243 | * @return string The sanitized slug.
244 | */
245 | private function sanitizeSlug(string $slug): string
246 | {
247 | return preg_replace('/[^a-zA-Z0-9_-]/', '', $slug);
248 | }
249 |
250 | /**
251 | * Handles dynamic routes not predefined in the Route enum.
252 | * Could be Route::Search & others instead maybe.
253 | *
254 | * @param string $route the route string from the 'go' parameter
255 | */
256 | private function handleDynamicRoute(string $route): void
257 | {
258 | if ($post = $this->postController->getRequestedPost($route)) {
259 | $this->postController->renderFullPost($post);
260 |
261 | return;
262 | }
263 |
264 | if ($category = StringUtils::extractCategoryFromRSS($route)) {
265 | $this->feedController->renderRSSByCategory($category);
266 |
267 | return;
268 | }
269 |
270 | if (Validator::isDateRoute($route)) {
271 | $this->postController->renderPostsByDate($route);
272 |
273 | return;
274 | }
275 |
276 | if (Validator::isValidCategoryPath($route)) {
277 | $this->postController->renderPostsByCategory($route);
278 |
279 | return;
280 | }
281 |
282 | if (Validator::isDraftsRoute($route)) {
283 | try {
284 | $slug = $this->sanitizeSlug(substr($route, 7));
285 | $this->postController->renderDraft($slug);
286 | } catch (\Weblog\Exception\NotFoundException $e) {
287 | $this->postController->handleNotFound();
288 | }
289 | return;
290 | }
291 |
292 | if (Validator::isSearchRoute($route)) {
293 | $matches = [];
294 | preg_match('#^search/(.+)$#', $route, $matches);
295 | $this->postController->renderSearchResults(urldecode($matches[1]));
296 |
297 | return;
298 | }
299 |
300 | if (Validator::isSelectedRoute($route)) {
301 | $this->postController->renderSelectedPosts();
302 |
303 | return;
304 | }
305 |
306 | $this->postController->handleNotFound();
307 | }
308 | }
309 |
--------------------------------------------------------------------------------
/src/Exception/NotFoundException.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Exception;
25 |
26 | final class NotFoundException extends \Exception {}
27 |
--------------------------------------------------------------------------------
/src/Model/Entity/Author.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model\Entity;
25 |
26 | use Weblog\Config;
27 |
28 | final class Author
29 | {
30 | /**
31 | * Initializes a new Author with specified details.
32 | *
33 | * @param string $name the name of the author
34 | * @param string $email the email address of the author
35 | * @param string $location the city or country of the author
36 | * @param string $aboutText a brief description or bio of the author
37 | */
38 | public function __construct(
39 | private readonly string $name = 'Unknown',
40 | private readonly string $email = 'no-reply@example.com',
41 | private readonly string $location = '',
42 | private string $aboutText = '',
43 | ) {}
44 |
45 | public function getName(): string
46 | {
47 | return $this->name;
48 | }
49 |
50 | public function getEmail(): string
51 | {
52 | return $this->email;
53 | }
54 |
55 | public function getAbout(): string
56 | {
57 | return $this->aboutText;
58 | }
59 |
60 | public function getInformation(): string
61 | {
62 | return $this->email ?? $this->name;
63 | }
64 |
65 | public function getLocation(): string
66 | {
67 | return $this->location;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Model/Entity/Post.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model\Entity;
25 |
26 | use Weblog\Config;
27 | use Weblog\Utils\StringUtils;
28 | use Weblog\Model\Enum\Beautify;
29 |
30 | final class Post
31 | {
32 | /**
33 | * Constructs a new Post instance with specified properties.
34 | *
35 | * @param string $title the title of the Post
36 | * @param string $path the file path of the Post
37 | * @param \DateTimeImmutable $date the last modified date the Post
38 | * @param bool $isDraft indicates if the Post is a draft
39 | * @param bool $isHidden indicates if the Post is hidden
40 | */
41 | public function __construct(
42 | private readonly string $title,
43 | private readonly string $path,
44 | private readonly \DateTimeImmutable $date,
45 | private bool $isDraft = false,
46 | private bool $isHidden = false
47 | ) {
48 | $this->isDraft = $isDraft;
49 | $this->isHidden = $isHidden;
50 | }
51 |
52 | /**
53 | * Checks if the post is a draft.
54 | *
55 | * @return bool True if the post is a draft, false otherwise.
56 | */
57 | public function isDraft(): bool
58 | {
59 | return $this->isDraft;
60 | }
61 |
62 | /**
63 | * Checks if the post is hidden.
64 | *
65 | * @param bool $checkPath Whether to check the path for hidden directories.
66 | *
67 | * @return bool True if the post is hidden, false otherwise.
68 | */
69 | public function isHidden(bool $checkPath = true): bool
70 | {
71 | return $this->isHidden || ($checkPath && $this->isHiddenPath($this->path));
72 | }
73 |
74 | /**
75 | * Determines if any component in the path is hidden.
76 | *
77 | * @param string $path The path to check.
78 | * @return bool Returns true if the path contains hidden components, false otherwise.
79 | */
80 | private function isHiddenPath(string $path): bool
81 | {
82 | $pathParts = explode(DIRECTORY_SEPARATOR, $path);
83 | foreach ($pathParts as $part) {
84 | if (str_starts_with($part, '.')) {
85 | return true;
86 | }
87 | }
88 | return false;
89 | }
90 |
91 | /**
92 | * Creates an instance from a file.
93 | *
94 | * This method extracts the title from the file's name, uses the full path as the path,
95 | * and sets the date based on the file's last modification time.
96 | *
97 | * @param \SplFileInfo $file the file from which to create the instance
98 | * @param bool $isDraft indicates if the file represents a draft
99 | *
100 | * @return self returns a Post instance populated with data from the file
101 | */
102 | public static function createFromFile(\SplFileInfo $file, bool $isDraft = false): self {
103 | $date = new \DateTimeImmutable('@'.$file->getMTime());
104 | $date = $date->setTimezone(new \DateTimeZone(date_default_timezone_get()));
105 | $isDraft = strpos($file->getPathname(), '/drafts/') !== false;
106 | $isHidden = str_starts_with(basename($file->getFilename(), '.'), '.');
107 |
108 | return new self(
109 | title: basename($file->getFilename(), '.txt'),
110 | path: $file->getPathname(),
111 | date: $date,
112 | isDraft: $isDraft,
113 | isHidden: $isHidden
114 | );
115 | }
116 |
117 | public function isSelected(): bool
118 | {
119 | return str_starts_with($this->title, '*');
120 | }
121 |
122 | public function getTitle(): string
123 | {
124 | $title = ltrim($this->title, '*.');
125 |
126 | $hideSelected = Config::get()->hideSelected;
127 |
128 | if ($this->isSelected() && !$hideSelected) {
129 | $title .= Config::get()->beautify === Beautify::OFF ? ' *' : ' ★';
130 | }
131 |
132 | return $title;
133 | }
134 |
135 | public function getSlug(): string
136 | {
137 | return StringUtils::slugify($this->getTitle());
138 | }
139 |
140 | public function getPath(): string
141 | {
142 | return $this->path;
143 | }
144 |
145 | public function getDate(): \DateTimeImmutable
146 | {
147 | return $this->date;
148 | }
149 |
150 | public function getDatetimestamp(): int
151 | {
152 | return $this->date->getTimestamp();
153 | }
154 |
155 | public function getFormattedDate(string $format = 'Y-m-d'): string
156 | {
157 | return $this->date->format($format);
158 | }
159 |
160 | public function getCategory(): string
161 | {
162 | $relativePath = str_replace(Config::get()->weblogDir, '', $this->getPath());
163 | $pathParts = explode('/', trim($relativePath, '/'));
164 |
165 | $category = (\count($pathParts) > 1) ? ucfirst(ltrim($pathParts[0], '.')) : 'Misc';
166 |
167 | return $category;
168 | }
169 |
170 | public function getContent(): string
171 | {
172 | $content = file_get_contents($this->path);
173 |
174 | return false === $content ? '' : $content;
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/src/Model/Enum/Beautify.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model\Enum;
25 |
26 | /**
27 | * Defines possible values of beautify config.
28 | */
29 | enum Beautify: string
30 | {
31 | case OFF = 'Off';
32 | case ALL = 'All';
33 | case CONTENT = 'Content';
34 | case RSS = 'RSS';
35 | }
36 |
--------------------------------------------------------------------------------
/src/Model/Enum/ContentType.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model\Enum;
25 |
26 | /**
27 | * Defines content types used within the weblog system.
28 | */
29 | enum ContentType: string
30 | {
31 | case TEXT = 'text/plain';
32 | case XML = 'application/xml';
33 | }
34 |
--------------------------------------------------------------------------------
/src/Model/Enum/ShowUrls.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model\Enum;
25 |
26 | /**
27 | * Defines possible values of show_urls config.
28 | */
29 | enum ShowUrls: string
30 | {
31 | case OFF = 'Off';
32 | case FULL = 'Full';
33 | case SHORT = 'Short';
34 | }
35 |
--------------------------------------------------------------------------------
/src/Model/PostCollection.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model;
25 |
26 | use Weblog\Model\Entity\Post;
27 | use Weblog\Utils\Validator;
28 |
29 | final class PostCollection implements \IteratorAggregate, \Countable
30 | {
31 | /**
32 | * Constructs a collection of posts.
33 | *
34 | * @param Post[] $posts array of Post objects
35 | */
36 | public function __construct(private array $posts = []) {}
37 |
38 | /**
39 | * Adds a Post object to the collection.
40 | *
41 | * @param Post $post the post to add to the collection
42 | */
43 | public function add(Post $post): void
44 | {
45 | $this->posts[] = $post;
46 | }
47 |
48 | /**
49 | * Sorts the posts in the collection by date, from newest to oldest.
50 | */
51 | public function sort(): void
52 | {
53 | usort($this->posts, static fn ($a, $b) => $b->getDateTimestamp() - $a->getDateTimestamp());
54 | }
55 |
56 | /**
57 | * Checks if the post collection is empty.
58 | *
59 | * @return bool returns true if the collection is empty, false otherwise
60 | */
61 | public function isEmpty(): bool
62 | {
63 | return [] === $this->posts;
64 | }
65 |
66 | /**
67 | * Retrieves the most recent date from the posts in the collection.
68 | *
69 | * @return null|\DateTimeImmutable returns the date of the most recent post in the collection, or null if the collection is empty
70 | */
71 | public function getMostRecentDate(): ?\DateTimeImmutable
72 | {
73 | if ($this->isEmpty()) {
74 | return null;
75 | }
76 |
77 | $this->sort();
78 |
79 | return $this->posts[0]->getDate();
80 | }
81 |
82 | /**
83 | * Filters posts by date, comparing only the date part.
84 | *
85 | * @param \DateTimeImmutable $date the date to match posts against
86 | *
87 | * @return PostCollection returns a new PostCollection containing only posts that match the given date
88 | */
89 | public function filterByDate(\DateTimeImmutable $date): self
90 | {
91 | $filteredPosts = [];
92 | foreach ($this->posts as $post) {
93 | if (Validator::dateMatches($date, $post)) {
94 | $filteredPosts[] = $post;
95 | }
96 | }
97 |
98 | return new self($filteredPosts);
99 | }
100 |
101 | /**
102 | * Generates a string of the range of years for the posts.
103 | *
104 | * @return string a formatted string, empty if no posts are present in the
105 | */
106 | public function getYearRange(): string
107 | {
108 | if (empty($this->posts)) {
109 | return '';
110 | }
111 |
112 | $dates = array_map(static fn (Post $post) => $post->getDate(), $this->posts);
113 |
114 | $minYear = min($dates)->format('Y');
115 | $maxYear = max($dates)->format('Y');
116 |
117 | return $minYear === $maxYear ? $minYear : "{$minYear}-{$maxYear}";
118 | }
119 |
120 | /**
121 | * Selects a random post from the collection.
122 | *
123 | * @return Post the selected random post
124 | */
125 | public function getRandomPost(): Post
126 | {
127 | $randomIndex = array_rand($this->posts);
128 |
129 | return $this->posts[$randomIndex];
130 | }
131 |
132 | /**
133 | * Returns the first post in the collection.
134 | *
135 | * @return Post|null returns the first Post object in the collection, or null if the collection is empty
136 | */
137 | public function getFirstPost(): ?Post
138 | {
139 | return $this->isEmpty() ? null : $this->posts[0];
140 | }
141 |
142 | /**
143 | * Returns the 1-based index of the provided post in the collection or null if not found.
144 | *
145 | * @param Post $post the post to find the index of
146 | *
147 | * @return string the index of the post as a string
148 | */
149 | public function getPostIndex(Post $post): string
150 | {
151 | $reversedPosts = array_reverse($this->posts);
152 | $index = array_search($post, $reversedPosts, true);
153 |
154 | if (false === $index) {
155 | throw new \InvalidArgumentException('Post not found in collection.');
156 | }
157 |
158 | $index = (int) $index;
159 |
160 | return (string) ($index + 1);
161 | }
162 |
163 | public function getIterator(): \ArrayIterator
164 | {
165 | return new \ArrayIterator($this->posts);
166 | }
167 |
168 | public function count(): int
169 | {
170 | return \count($this->posts);
171 | }
172 |
173 | /**
174 | * @param callable $callback a callback function that returns true if the post should be included
175 | *
176 | * @return PostCollection a new collection with the filtered posts
177 | */
178 | public function filter(callable $callback): self
179 | {
180 | $filteredPosts = array_filter($this->posts, $callback);
181 |
182 | return new self($filteredPosts);
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/src/Model/PostRepository.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model;
25 |
26 | use Weblog\Model\Entity\Post;
27 | use Weblog\Utils\Validator;
28 | use Weblog\Utils\StringUtils;
29 |
30 | final class PostRepository
31 | {
32 | private \Iterator $iterator;
33 |
34 | /**
35 | * Constructor for PostRepository.
36 | * Initializes the iterator based on the given directory or the default directory specified in configuration.
37 | *
38 | * @param string $directory The directory path to initialize the iterator
39 | */
40 | public function __construct(private string $directory)
41 | {
42 | $this->loadIterator();
43 | }
44 |
45 | /**
46 | * Fetches all posts from the weblog directory, sorted from newest to oldest.
47 | *
48 | * @return PostCollection an array of Posts objects inside a PostCollection
49 | */
50 | public function fetchAllPosts(): PostCollection
51 | {
52 | $posts = new PostCollection();
53 | foreach ($this->iterator as $file) {
54 | if ($file instanceof \SplFileInfo) {
55 | $post = Post::createFromFile($file);
56 | if ($post->isHidden() || $post->isDraft()) {
57 | continue;
58 | }
59 | $posts->add($post);
60 | }
61 | }
62 | $posts->sort();
63 | return $posts;
64 | }
65 |
66 | /**
67 | * Retrieves the specific post based on the requested slug.
68 | *
69 | * @param string $slug the slug of the post to find
70 | * @param null|string $directory the path of the directory to search
71 | *
72 | * @return null|Post the file info of the requested post or null if not found
73 | */
74 | public function fetchPostInDirectory(string $slug, ?string $directory = null): ?Post
75 | {
76 | $this->setDirectory($directory ?? $this->directory);
77 | foreach ($this->iterator as $file) {
78 | if ($file instanceof \SplFileInfo) {
79 | $post = Post::createFromFile($file);
80 | $postSlug = ltrim($post->getSlug(), '.');
81 |
82 | if ($slug === $postSlug && !$post->isDraft()) {
83 | return $post;
84 | }
85 | }
86 | }
87 |
88 | return null;
89 | }
90 |
91 | /**
92 | * Fetches posts filtered by a specific date. If no date is provided, the current date is used.
93 | *
94 | * @param \DateTimeImmutable $date The date to filter the posts by. Defaults to the current date if null.
95 | *
96 | * @return PostCollection returns a collection of posts that match the given date
97 | */
98 | public function fetchPostsByDate(\DateTimeImmutable $date, string $precision): PostCollection
99 | {
100 | $posts = $this->fetchAllPosts();
101 |
102 | return $posts->filter(static function (Post $post) use ($date, $precision) {
103 | switch ($precision) {
104 | case 'year':
105 | return $date->format('Y') === $post->getDate()->format('Y');
106 | case 'month':
107 | return $date->format('Y-m') === $post->getDate()->format('Y-m');
108 | case 'day':
109 | return $date->format('Y-m-d') === $post->getDate()->format('Y-m-d');
110 | default:
111 | return false;
112 | }
113 | });
114 | }
115 |
116 | /**
117 | * Fetches all posts from a specified category.
118 | *
119 | * This method filters the posts based on the given category. It includes posts
120 | * that are within the specified hidden category but excludes posts that start with a dot.
121 | *
122 | * @param string $category The category to filter posts by. 'misc' will also fetch posts that do not belong to any category.
123 | *
124 | * @return PostCollection returns a collection of posts filtered by the specified category.
125 | */
126 | public function fetchPostsByCategory(string $category): PostCollection
127 | {
128 | $posts = new PostCollection();
129 | foreach ($this->iterator as $file) {
130 | if ($file instanceof \SplFileInfo) {
131 | $post = Post::createFromFile($file);
132 | $categorySlug = StringUtils::slugify($category);
133 |
134 | if ($post->isHidden($checkPath = false)) {
135 | continue;
136 | }
137 |
138 | $directoryName = ltrim($file->getPathInfo()->getFilename(), '.');
139 | if (($categorySlug === StringUtils::slugify($directoryName)) || Validator::isValidCategoryPost($file, $category, $this->directory)) {
140 | if (!$post->isDraft()) {
141 | $posts->add($post);
142 | }
143 | }
144 | }
145 | }
146 |
147 | $posts->sort();
148 |
149 | return $posts;
150 | }
151 |
152 | /**
153 | * Retrieves the range of years (earliest and latest) from all posts.
154 | *
155 | * @return string range of years for all posts
156 | */
157 | public function getPostYearsRange(): string
158 | {
159 | $posts = $this->fetchAllPosts();
160 |
161 | return $posts->getYearRange();
162 | }
163 |
164 | /**
165 | * Fetches posts from a specific date.
166 | *
167 | * @param \DateTimeImmutable $date The date to start from
168 | *
169 | * @return PostCollection returns a collection of posts from the specified date
170 | */
171 | public function fetchPostsFromDate(\DateTimeImmutable $date): PostCollection
172 | {
173 | $posts = new PostCollection();
174 | foreach ($this->iterator as $file) {
175 | if ($file instanceof \SplFileInfo) {
176 | $post = Post::createFromFile($file);
177 | if ($post->getDate() >= $date) {
178 | $posts->add($post);
179 | }
180 | }
181 | }
182 | $posts->sort();
183 |
184 | return $posts;
185 | }
186 |
187 | /**
188 | * Fetches posts from a specific date range.
189 | *
190 | * @param \DateTimeImmutable $startDate The start date of the range
191 | * @param \DateTimeImmutable $endDate The end date of the range
192 | *
193 | * @return PostCollection returns a collection of posts from the specified date range
194 | */
195 | public function fetchPostsFromDateRange(\DateTimeImmutable $startDate, \DateTimeImmutable $endDate): PostCollection
196 | {
197 | $posts = new PostCollection();
198 | foreach ($this->iterator as $file) {
199 | if ($file instanceof \SplFileInfo) {
200 | $post = Post::createFromFile($file);
201 | if ($post->getDate() >= $startDate && $post->getDate() <= $endDate && !$post->isDraft()) {
202 | $posts->add($post);
203 | }
204 | }
205 | }
206 | $posts->sort();
207 |
208 | return $posts;
209 | }
210 |
211 | /**
212 | * Fetches selected posts from the weblog directory.
213 | *
214 | * A selected post is identified by an asterisk (*) at the beginning of its title.
215 | * The method collects all such posts and returns them sorted from newest to oldest.
216 | *
217 | * @return PostCollection an array of Post objects inside a PostCollection
218 | */
219 | public function fetchSelectedPosts(): PostCollection
220 | {
221 | $posts = new PostCollection();
222 | foreach ($this->iterator as $file) {
223 | if ($file instanceof \SplFileInfo) {
224 | $post = Post::createFromFile($file);
225 | if ($post->isSelected() && !$post->isDraft()) {
226 | $posts->add($post);
227 | }
228 | }
229 | }
230 | $posts->sort();
231 |
232 | return $posts;
233 | }
234 |
235 | /**
236 | * Searches posts by query.
237 | *
238 | * @param string $query the search query
239 | *
240 | * @return PostCollection returns a collection of posts matching the query
241 | */
242 | public function searchPosts(string $query): PostCollection
243 | {
244 | $posts = new PostCollection();
245 | foreach ($this->iterator as $file) {
246 | if ($file instanceof \SplFileInfo) {
247 | $post = Post::createFromFile($file);
248 | if (!$post->isDraft() &&
249 | (StringUtils::containsIgnoreCaseAndDiacritics($post->getTitle(), $query) ||
250 | StringUtils::containsIgnoreCaseAndDiacritics($post->getContent(), $query))
251 | ) {
252 | $posts->add($post);
253 | }
254 | }
255 | }
256 | $posts->sort();
257 |
258 | return $posts;
259 | }
260 |
261 | /**
262 | * Fetches a draft by its slug.
263 | *
264 | * @param string $slug The slug of the draft to find.
265 | * @return null|Post The draft post or null if not found.
266 | */
267 | public function fetchDraftBySlug(string $slug): ?Post
268 | {
269 | $draftsDir = $this->directory . '/drafts';
270 | if (!is_dir($draftsDir)) {
271 | throw new \RuntimeException("Drafts directory not found: {$draftsDir}");
272 | }
273 | $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($draftsDir, \RecursiveDirectoryIterator::SKIP_DOTS));
274 | foreach ($iterator as $file) {
275 | if ($file instanceof \SplFileInfo) {
276 | $post = Post::createFromFile($file, true);
277 | if ($slug === $post->getSlug()) {
278 | return $post;
279 | }
280 | }
281 | }
282 | return null;
283 | }
284 |
285 | /**
286 | * Sets the directory for the iterator and resets the iterator to reflect the new directory.
287 | * This is necessary to ensure the iterator points to the correct directory.
288 | *
289 | * @param string $newDirectory the new directory path to set
290 | */
291 | public function setDirectory(string $newDirectory): void
292 | {
293 | if (!is_dir($newDirectory)) {
294 | throw new \InvalidArgumentException("The specified directory does not exist or is not a directory: {$newDirectory}");
295 | }
296 |
297 | $this->directory = $newDirectory;
298 | }
299 |
300 | private function loadIterator(): void
301 | {
302 | $directoryIterator = new \RecursiveDirectoryIterator($this->directory, \RecursiveDirectoryIterator::SKIP_DOTS);
303 | $recursiveIterator = new \RecursiveIteratorIterator($directoryIterator);
304 |
305 | $this->iterator = new \CallbackFilterIterator($recursiveIterator, function ($file) {
306 | return $file->isFile() && $file->getExtension() === 'txt';
307 | });
308 | }
309 | }
310 |
--------------------------------------------------------------------------------
/src/Model/Route.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Model;
25 |
26 | enum Route: string
27 | {
28 | case SITEMAP = 'sitemap.xml';
29 | case RSS = 'rss';
30 | case RANDOM = 'random';
31 | case LATEST = 'latest';
32 | case LATEST_YEAR = 'latest/year';
33 | case LATEST_MONTH = 'latest/month';
34 | case LATEST_WEEK = 'latest/week';
35 | case LATEST_DAY = 'latest/day';
36 | }
37 |
--------------------------------------------------------------------------------
/src/Utils/ContentFormatter.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | use Weblog\Config;
27 | use Weblog\Model\Enum\Beautify;
28 |
29 | final class ContentFormatter
30 | {
31 | /**
32 | * Formats the content of a post into paragraphs.
33 | *
34 | * @param string $content the raw content of the post
35 | *
36 | * @return string the formatted content
37 | */
38 | public static function formatPostContent(string $content): string
39 | {
40 | $paragraphs = preg_split('/\n\s*\n/', trim($content));
41 | $formattedContent = '';
42 |
43 | foreach ($paragraphs as $paragraph) {
44 | $paragraph = preg_replace('/`([^`]*)`/', '$1', $paragraph);
45 |
46 | if (!Validator::isMobileDevice()) {
47 | $trimmedParagraph = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', trim($paragraph));
48 | } else {
49 | $trimmedParagraph = trim($paragraph);
50 | }
51 |
52 | if (preg_match('/^(#+)\s*(.*)$/', $trimmedParagraph, $matches)) {
53 | $text = $matches[2];
54 | if (!Validator::isMobileDevice()) {
55 | $formattedContent .= "\n" . TextUtils::centerText($text) . "\n\n\n";
56 | } else {
57 | $formattedContent .= "\n" . " " . TextUtils::centerText($text) . "\n\n\n";
58 | }
59 | continue;
60 | }
61 |
62 | if (str_starts_with($trimmedParagraph, '>')) {
63 | $formattedContent .= TextUtils::formatQuote($paragraph) . "\n\n";
64 | } elseif (preg_match('/^(\d+)\.\s/', $trimmedParagraph, $matches) || preg_match('/^\* /', $trimmedParagraph)) {
65 | $formattedContent .= TextUtils::formatList($paragraph) . "\n\n";
66 | } else {
67 | $lines = explode("\n", $trimmedParagraph);
68 | foreach ($lines as $line) {
69 | $formattedContent .= TextUtils::formatParagraph(trim($line)) . "\n";
70 | }
71 | $formattedContent .= "\n";
72 | }
73 | }
74 |
75 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) {
76 | $formattedContent = StringUtils::beautifyText($formattedContent);
77 | }
78 |
79 | return rtrim($formattedContent) . "\n\n";
80 | }
81 |
82 | /**
83 | * Formats the header of a post, including category, title, and publication date.
84 | * Adjusts dynamically based on device type and enabled settings.
85 | *
86 | * @param string $title the title of the post
87 | * @param string $category the category of the post (optional)
88 | * @param string $date the publication date of the post (optional)
89 | *
90 | * @return string the formatted header
91 | */
92 | public static function formatPostHeader(string $title = '', string $category = '', string $date = ''): string
93 | {
94 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) {
95 | $title = StringUtils::beautifyText($title);
96 | }
97 |
98 | $lineWidth = Config::get()->lineWidth;
99 | $includeCategory = Config::get()->showCategory && !empty($category);
100 | $includeDate = Config::get()->showDate && !empty($date);
101 |
102 | $availableWidth = $lineWidth;
103 | $categoryWidth = $includeCategory ? 20 : 0;
104 | $dateWidth = $includeDate ? 20 : 0;
105 | $titleWidth = $availableWidth - $categoryWidth - $dateWidth;
106 |
107 | if (mb_strlen($title) > 32) {
108 | $titleLines = wordwrap($title, 32, "\n", true);
109 | $titleParts = explode("\n", $titleLines);
110 | } else {
111 | $titleParts = [$title];
112 | }
113 |
114 | $header = '';
115 |
116 | foreach ($titleParts as $index => $titleLine) {
117 | $titlePaddingLeft = (int)(($titleWidth - mb_strlen($titleLine)) / 2);
118 | $titlePaddingRight = $titleWidth - mb_strlen($titleLine) - $titlePaddingLeft;
119 |
120 | if (Validator::isMobileDevice()) {
121 | $titlePaddingLeft += 1;
122 | }
123 |
124 | if ($index > 0) {
125 | $header .= "\n" . str_repeat(' ', $titlePaddingLeft + $categoryWidth) . $titleLine;
126 | } else {
127 | if ($includeCategory) {
128 | $header .= str_pad($category, $categoryWidth);
129 | }
130 | $header .= str_repeat(' ', $titlePaddingLeft) . $titleLine . str_repeat(' ', $titlePaddingRight);
131 | if ($includeDate) {
132 | if (Config::get()->shortenDate) {
133 | $date = (new \DateTime($date))->format('j M \'y');
134 | }
135 | $header .= str_pad($date, $dateWidth, ' ', STR_PAD_LEFT);
136 | }
137 | }
138 | }
139 |
140 | return StringUtils::capitalizeText($header);
141 | }
142 |
143 | /**
144 | * Formats the given content into RSS-compatible HTML.
145 | *
146 | * @param string $content the raw content to be formatted for RSS
147 | *
148 | * @return string the formatted content as HTML paragraphs
149 | */
150 | public static function formatRssContent(string $content): string
151 | {
152 | $paragraphs = explode("\n", trim($content));
153 | $formattedContent = '';
154 | $insideBlockquote = false;
155 | $blockquoteContent = '';
156 | $insideList = false;
157 | $listContent = '';
158 | $listType = '';
159 | $listItems = [];
160 |
161 | foreach ($paragraphs as $paragraph) {
162 | $trimmedParagraph = trim($paragraph);
163 |
164 | if (preg_match('/^(#+)\s*(.*)$/', $trimmedParagraph, $matches)) {
165 | $level = strlen($matches[1]);
166 | $heading = htmlspecialchars($matches[2]);
167 | $tag = 'h' . min($level, 6);
168 |
169 | $formattedContent .= "<{$tag}>" . $heading . "{$tag}>\n";
170 | continue;
171 | }
172 |
173 | if (str_starts_with($trimmedParagraph, '>')) {
174 | $quoteText = substr($trimmedParagraph, 1);
175 | if (!$insideBlockquote) {
176 | $insideBlockquote = true;
177 | $blockquoteContent .= '';
178 | }
179 | $blockquoteContent .= ltrim(htmlspecialchars($quoteText)) . '
';
180 | } elseif (preg_match('/^(\d+)\.\s/', $trimmedParagraph, $matches) || preg_match('/^\* /', $trimmedParagraph)) {
181 | $listType = isset($matches[1]) ? 'ol' : 'ul';
182 |
183 | if (!$insideList) {
184 | $insideList = true;
185 | $listItems = [];
186 | }
187 |
188 | $itemText = isset($matches[1]) ? trim(substr($trimmedParagraph, strlen($matches[0]))) : trim(substr($trimmedParagraph, 2));
189 | $listItems[] = htmlspecialchars($itemText);
190 | } else {
191 | if ($insideBlockquote) {
192 | $insideBlockquote = false;
193 | $blockquoteContent .= '
';
194 | $formattedContent .= $blockquoteContent;
195 | $blockquoteContent = '';
196 | }
197 |
198 | if ($insideList) {
199 | $insideList = false;
200 | if (count($listItems) == 1 && $listType === 'ol') {
201 | $formattedContent .= '' . $listItems[0] . '
';
202 | } else {
203 | $listContent = $listType === 'ol' ? '' : '';
204 | foreach ($listItems as $item) {
205 | $listContent .= '- ' . $item . '
';
206 | }
207 | $listContent .= $listType === 'ol' ? '
' : '';
208 | $formattedContent .= $listContent;
209 | }
210 | $listItems = [];
211 | }
212 |
213 | if (!empty($trimmedParagraph)) {
214 | $formattedContent .= '' . htmlspecialchars($trimmedParagraph) . '
';
215 | }
216 | }
217 | }
218 |
219 | if ($insideBlockquote) {
220 | $blockquoteContent .= '';
221 | $formattedContent .= $blockquoteContent;
222 | }
223 |
224 | if ($insideList && count($listItems) == 1 && $listType === 'ol') {
225 | $formattedContent .= '' . $listItems[0] . '
';
226 | } elseif ($insideList) {
227 | $listContent = $listType === 'ol' ? '' : '';
228 | foreach ($listItems as $item) {
229 | $listContent .= '- ' . $item . '
';
230 | }
231 | $listContent .= $listType === 'ol' ? '
' : '';
232 | $formattedContent .= $listContent;
233 | }
234 |
235 | $formattedContent = str_replace('
', '', $formattedContent);
236 |
237 | return $formattedContent;
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/src/Utils/Factory.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | use Weblog\Config;
27 | use Weblog\Controller\FeedController;
28 | use Weblog\Controller\PostController;
29 | use Weblog\Controller\Router;
30 | use Weblog\Model\PostRepository;
31 |
32 | final class Factory
33 | {
34 | /**
35 | * Create new Router instance.
36 | */
37 | public static function createRouter(): Router
38 | {
39 | return new Router(self::createPostController(), self::createFeedController());
40 | }
41 |
42 | private static function createPostController(): PostController
43 | {
44 | return new PostController(self::createPostRepostory());
45 | }
46 |
47 | private static function createFeedController(): FeedController
48 | {
49 | return new FeedController(self::createPostRepostory());
50 | }
51 |
52 | private static function createPostRepostory(): PostRepository
53 | {
54 | return new PostRepository(Config::get()->weblogDir);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Utils/FeedGenerator.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | use Weblog\Config;
27 | use Weblog\Model\Entity\Post;
28 | use Weblog\Model\PostCollection;
29 | use Weblog\Model\Enum\Beautify;
30 |
31 | final class FeedGenerator
32 | {
33 | /**
34 | * Creates an XML sitemap from a list of posts.
35 | *
36 | * This method takes an array of Post objects and a domain string to construct
37 | * an XML sitemap compliant with the sitemap protocol. The sitemap lists all posts,
38 | * sorting them from the most recent based on their dates, and includes the main page.
39 | *
40 | * @param PostCollection $posts a collection of Post objects to be included in the sitemap
41 | *
42 | * @return \SimpleXMLElement the XML element of the generated sitemap
43 | */
44 | public static function generateSiteMap(PostCollection $posts): \SimpleXMLElement
45 | {
46 | $sitemap = new \SimpleXMLElement('');
47 | $lastmodDate = $posts->getMostRecentDate() ?? new \DateTimeImmutable();
48 | self::appendXmlElement($sitemap, 'url', null, [], [
49 | 'loc' => Config::get()->url.'/',
50 | 'lastmod' => $lastmodDate->format('Y-m-d'),
51 | 'priority' => '1.0',
52 | 'changefreq' => 'daily',
53 | ]);
54 |
55 | foreach ($posts as $post) {
56 | if (!$post instanceof Post) {
57 | continue;
58 | }
59 | self::appendXmlElement($sitemap, 'url', null, [], [
60 | 'loc' => Config::get()->url.'/'.StringUtils::slugify($post->getTitle()).'/',
61 | 'lastmod' => $post->getFormattedDate(),
62 | 'priority' => '1.0',
63 | 'changefreq' => 'weekly',
64 | ]);
65 | }
66 |
67 | return $sitemap;
68 | }
69 |
70 | /**
71 | * Generates an RSS feed for a collection of posts.
72 | *
73 | * @param PostCollection $posts the collection of posts to be included in the feed
74 | * @param string $category the of posts
75 | *
76 | * @return \SimpleXMLElement the RSS feed
77 | */
78 | public static function generateRSS(PostCollection $posts, string $category = ''): \SimpleXMLElement
79 | {
80 | $rss = new \SimpleXMLElement('');
81 | $channel = $rss->addChild('channel');
82 | $lastmodDate = $posts->getMostRecentDate() ?? new \DateTimeImmutable();
83 | $titleSuffix = '' !== $category ? ' — '.ucfirst($category) : $category;
84 |
85 | $href = Config::get()->url . '/rss/';
86 | if ($category !== '') {
87 | $href .= StringUtils::slugify($category) . '/';
88 | }
89 |
90 | self::appendXmlElement($channel, 'title', Config::get()->author->getName().$titleSuffix);
91 | self::appendXmlElement($channel, 'link', Config::get()->url.'/');
92 | self::appendAtomLink($channel, $href);
93 | self::appendXmlElement($channel, 'description', preg_split('/\n{3,}/', Config::get()->author->getAbout())[0] ?? '');
94 | self::appendXmlElement($channel, 'language', 'en');
95 | self::appendXmlElement($channel, 'generator', 'Weblog v'.Config::get()->version);
96 | self::appendXmlElement($channel, 'lastBuildDate', $lastmodDate->format(DATE_RSS));
97 |
98 | self::appendPostItems($posts, $channel);
99 |
100 | return $rss;
101 | }
102 |
103 | /**
104 | * Appends an XML element with attributes to a parent XML element.
105 | *
106 | * @param \SimpleXMLElement $parent the parent XML element
107 | * @param string $name the tag name of the child element
108 | * @param null|string $value the value of the child element
109 | * @param array $attributes an associative array of attributes for the child element
110 | * @param array $subelements an associative array of subelements
111 | */
112 | private static function appendXmlElement(
113 | \SimpleXMLElement $parent,
114 | string $name,
115 | ?string $value = null,
116 | array $attributes = [],
117 | array $subelements = [],
118 | ): void {
119 | if (null !== $value || !empty($subelements)) {
120 | $element = $parent->addChild($name, $value);
121 | foreach ($attributes as $key => $val) {
122 | $element->addAttribute($key, $val);
123 | }
124 | foreach ($subelements as $subName => $subValue) {
125 | if (!empty($subValue)) {
126 | $element->addChild($subName, $subValue);
127 | }
128 | }
129 | }
130 | }
131 |
132 | /**
133 | * Appends an Atom link to the channel element.
134 | *
135 | * @param \SimpleXMLElement $channel the parent channel XML element
136 | * @param string $href the href attribute for the Atom link
137 | */
138 | private static function appendAtomLink(\SimpleXMLElement $channel, string $href): void
139 | {
140 | $atomLink = $channel->addChild('link', null, 'http://www.w3.org/2005/Atom');
141 | $atomLink->addAttribute('href', $href);
142 | $atomLink->addAttribute('rel', 'self');
143 | $atomLink->addAttribute('type', 'application/rss+xml');
144 | }
145 |
146 | /**
147 | * Adds post items to the RSS channel.
148 | *
149 | * @param PostCollection $posts collection of posts to be included
150 | * @param \SimpleXMLElement $channel the channel XML element
151 | */
152 | private static function appendPostItems(PostCollection $posts, \SimpleXMLElement $channel): void
153 | {
154 | foreach ($posts as $post) {
155 | if (!$post instanceof Post) {
156 | continue;
157 | }
158 |
159 | $item = $channel->addChild('item');
160 | $title = htmlspecialchars($post->getTitle(), ENT_XML1, 'UTF-8');
161 |
162 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::RSS])) {
163 | $title = StringUtils::beautifyText($title);
164 | }
165 |
166 | self::appendXmlElement($item, 'title', $title);
167 | foreach (['guid', 'link'] as $tag) {
168 | self::appendXmlElement($item, $tag, Config::get()->url.'/'.$post->getSlug().'/');
169 | }
170 | self::appendXmlElement($item, 'pubDate', $post->getFormattedDate(DATE_RSS));
171 | self::appendXmlElement($item, 'category', $post->getCategory());
172 |
173 | $description = $post->getContent();
174 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::RSS])) {
175 | $description = StringUtils::beautifyText($description);
176 | }
177 | $description = self::formatHyperlinks($description);
178 | $description = self::formatCode($description);
179 | self::appendXmlElement($item, 'description', ContentFormatter::formatRssContent($description));
180 | }
181 | }
182 |
183 | /**
184 | * Converts plain text URLs to hyperlinks.
185 | *
186 | * @param string $text The input text containing URLs.
187 | *
188 | * @return string The text with URLs converted to hyperlinks.
189 | */
190 | private static function formatHyperlinks(string $text): string
191 | {
192 | $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443)
193 | ? 'https://'
194 | : 'http://';
195 |
196 | $text = preg_replace_callback('/^> ([^\s]+\.[^\s]+)/m', function ($matches) {
197 | return $matches[1];
198 | }, $text);
199 |
200 | return preg_replace_callback(
201 | '/(?' . $match . '';
207 | }
208 |
209 | return '' . $match . '';
210 | },
211 | $text
212 | );
213 | }
214 |
215 | /**
216 | * Formats backticked text as code in RSS.
217 | *
218 | * @param string $text the raw text potentially containing backticked code
219 | *
220 | * @return string the formatted text for RSS
221 | */
222 | private static function formatCode(string $text): string
223 | {
224 | $pattern = '/`([^`]*)`/';
225 | $replacePairs = [
226 | '“' => '"',
227 | '”' => '"',
228 | '‘' => "'",
229 | '’' => "'",
230 | '—' => '-',
231 | ];
232 |
233 | $callback = function ($matches) use ($replacePairs) {
234 | return '' . strtr($matches[1], $replacePairs) . '
';
235 | };
236 |
237 | return preg_replace_callback($pattern, $callback, $text);
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/src/Utils/HttpUtils.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | /**
27 | * Utility class for common HTTP operations.
28 | */
29 | final class HttpUtils
30 | {
31 | /**
32 | * Redirects to a specified URL with a given status code.
33 | *
34 | * ..."Have you mooed today?"...
35 | *
36 | * @param string $url The URL to redirect to.
37 | * @param int $statusCode The HTTP status code for the redirection. Default is 301 (Moved Permanently).
38 | * @throws \InvalidArgumentException if the provided status code is not 301 or 302.
39 | */
40 | public static function redirect(string $url, int $statusCode = 301): void
41 | {
42 | if (!in_array($statusCode, [301, 302], true)) {
43 | throw new \InvalidArgumentException("Invalid HTTP status code: $statusCode. Only 301 and 302 are supported.");
44 | }
45 |
46 | $messages = [
47 | 301 => "Moved Permanently",
48 | 302 => "Found"
49 | ];
50 |
51 | $message = $messages[$statusCode];
52 |
53 | header("Location: $url", true, $statusCode);
54 |
55 | if ($statusCode === 301 && random_int(1, 10) === 1) {
56 | echo <<
59 | ---------------------
60 | \ ^__^
61 | \ (oo)\_______
62 | (__)\ )\/\
63 | ||----w |
64 | || ||
65 |
66 | $url
67 | EOT;
68 |
69 | } else {
70 | echo "$statusCode $message\n\n$url";
71 | }
72 |
73 | exit;
74 | }
75 |
76 | /**
77 | * Retrieves the current scheme (http or https) of the request.
78 | *
79 | * @return string The current scheme.
80 | */
81 | public static function getScheme(): string
82 | {
83 | return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https" : "http";
84 | }
85 |
86 | /**
87 | * Retrieves the current host of the request.
88 | *
89 | * @return string The host.
90 | */
91 | public static function getHost(): string
92 | {
93 | return $_SERVER['HTTP_HOST'] ?? 'localhost';
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/Utils/Logger.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | use Weblog\Config;
27 |
28 | final class Logger
29 | {
30 | private static ?self $instance = null;
31 | private string $logFilePath;
32 |
33 | /**
34 | * Private constructor to prevent direct instantiation.
35 | */
36 | private function __construct(string $logFilePath)
37 | {
38 | $this->logFilePath = $logFilePath;
39 | }
40 |
41 | /**
42 | * Get the singleton instance of Logger.
43 | */
44 | public static function getInstance(string $logFilePath): self
45 | {
46 | if (null === self::$instance) {
47 | self::$instance = new self($logFilePath);
48 | }
49 | return self::$instance;
50 | }
51 |
52 | /**
53 | * Log the access information in Nginx format.
54 | */
55 | public function log(): void
56 | {
57 | if (!isset($_SERVER['REMOTE_ADDR']) || !isset($_SERVER['REQUEST_METHOD']) || !isset($_SERVER['REQUEST_URI']) || !isset($_SERVER['SERVER_PROTOCOL'])) {
58 | return;
59 | }
60 |
61 | $ip = $_SERVER['REMOTE_ADDR'];
62 | $method = $_SERVER['REQUEST_METHOD'];
63 | $uri = $_SERVER['REQUEST_URI'];
64 | $protocol = $_SERVER['SERVER_PROTOCOL'];
65 | $status = http_response_code();
66 | $size = ob_get_length();
67 | $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '-';
68 | $referer = $_SERVER['HTTP_REFERER'] ?? '-';
69 |
70 | $filterWords = Config::get()->logFilterWords;
71 | if (!empty($filterWords)) {
72 | foreach ($filterWords as $word) {
73 | if (stripos($uri, $word) !== false) {
74 | return;
75 | }
76 | }
77 | }
78 |
79 | $filterAgents = Config::get()->logFilterAgents;
80 | if (!empty($filterAgents)) {
81 | foreach ($filterAgents as $agent) {
82 | if (stripos($userAgent, $agent) !== false) {
83 | return;
84 | }
85 | }
86 | }
87 |
88 | $logEntry = sprintf(
89 | "%s - - [%s] \"%s %s %s\" %d %d \"%s\" \"%s\"",
90 | $ip,
91 | date('d/M/Y:H:i:s O'),
92 | $method,
93 | $uri,
94 | $protocol,
95 | $status,
96 | $size,
97 | $referer,
98 | $userAgent
99 | );
100 |
101 | file_put_contents($this->logFilePath, $logEntry.PHP_EOL, FILE_APPEND | LOCK_EX);
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/Utils/StringUtils.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | use Weblog\Config;
27 | use Weblog\Model\Enum\ShowUrls;
28 |
29 | final class StringUtils
30 | {
31 | /**
32 | * Converts a string to a URL-friendly slug, ensuring non-ASCII characters are appropriately replaced.
33 | *
34 | * @param string $title the string to slugify
35 | *
36 | * @return string the slugified string
37 | */
38 | public static function slugify($title): string
39 | {
40 | $title = ltrim($title, '.');
41 | $title = mb_strtolower($title, 'UTF-8');
42 | $replacements = [
43 | '/а/u' => 'a', '/б/u' => 'b', '/в/u' => 'v', '/г/u' => 'g', '/д/u' => 'd',
44 | '/е/u' => 'e', '/ё/u' => 'yo', '/ж/u' => 'zh', '/з/u' => 'z', '/и/u' => 'i',
45 | '/й/u' => 'y', '/к/u' => 'k', '/л/u' => 'l', '/м/u' => 'm', '/н/u' => 'n',
46 | '/о/u' => 'o', '/п/u' => 'p', '/р/u' => 'r', '/с/u' => 's', '/т/u' => 't',
47 | '/у/u' => 'u', '/ф/u' => 'f', '/х/u' => 'h', '/ц/u' => 'ts', '/ч/u' => 'ch',
48 | '/ш/u' => 'sh', '/щ/u' => 'sch', '/ъ/u' => '', '/ы/u' => 'y', '/ь/u' => '',
49 | '/э/u' => 'e', '/ю/u' => 'yu', '/я/u' => 'ya',
50 | ];
51 | $title = preg_replace(array_keys($replacements), array_values($replacements), $title);
52 |
53 | if (null === $title) {
54 | throw new \RuntimeException('Failed to slugify title.');
55 | }
56 |
57 | $title = preg_replace('/[\'"‘’“”«»]/u', '', $title);
58 |
59 | $title = preg_replace_callback('/(?<=[a-z])\'(?=[a-z])/i', function() {
60 | return '-';
61 | }, $title);
62 |
63 | $title = iconv('UTF-8', 'ASCII//TRANSLIT', $title) ?: '';
64 | $title = preg_replace('/[^a-z0-9\s-]/', '-', $title) ?: '';
65 | $title = preg_replace('/\s+/', '-', $title ?: '');
66 | $title = preg_replace('/-+/', '-', $title);
67 |
68 | if (null === $title) {
69 | throw new \RuntimeException('Failed to slugify title.');
70 | }
71 |
72 | $title = trim($title, '-');
73 |
74 | if ('' === $title) {
75 | throw new \RuntimeException("Failed to generate a valid slug from title: {$title}");
76 | }
77 |
78 | return $title;
79 | }
80 |
81 | /**
82 | * Removes diacritics from the given string.
83 | *
84 | * This function replaces diacritic characters with their closest ASCII equivalents.
85 | *
86 | * @param string $text The text from which diacritics should be removed.
87 | * @return string The text with diacritics removed.
88 | */
89 | public static function removeDiacritics(string $text): string
90 | {
91 | $normalizeChars = array(
92 | 'Š'=>'S', 'š'=>'s', 'Ž'=>'Z', 'ž'=>'z', 'À'=>'A', 'Á'=>'A', 'Â'=>'A', 'Ã'=>'A', 'Ä'=>'A', 'Å'=>'A', 'Æ'=>'A', 'Ç'=>'C', 'È'=>'E', 'É'=>'E',
93 | 'Ê'=>'E', 'Ë'=>'E', 'Ì'=>'I', 'Í'=>'I', 'Î'=>'I', 'Ï'=>'I', 'Ñ'=>'N', 'Ò'=>'O', 'Ó'=>'O', 'Ô'=>'O', 'Õ'=>'O', 'Ö'=>'O', 'Ø'=>'O', 'Ù'=>'U',
94 | 'Ú'=>'U', 'Û'=>'U', 'Ü'=>'U', 'Ý'=>'Y', 'Þ'=>'B', 'ß'=>'Ss', 'à'=>'a', 'á'=>'a', 'â'=>'a', 'ã'=>'a', 'ä'=>'a', 'å'=>'a', 'æ'=>'a', 'ç'=>'c',
95 | 'è'=>'e', 'é'=>'e', 'ê'=>'e', 'ë'=>'e', 'ì'=>'i', 'í'=>'i', 'î'=>'i', 'ï'=>'i', 'ð'=>'o', 'ñ'=>'n', 'ò'=>'o', 'ó'=>'o', 'ô'=>'o', 'õ'=>'o',
96 | 'ö'=>'o', 'ø'=>'o', 'ù'=>'u', 'ú'=>'u', 'û'=>'u', 'ü'=>'u', 'ý'=>'y', 'þ'=>'b', 'ÿ'=>'y', 'Ŕ'=>'R', 'ŕ'=>'r', 'ŕ'=>'r'
97 | );
98 |
99 | return strtr($text, $normalizeChars);
100 | }
101 |
102 | /**
103 | * Checks if a string contains another string, ignoring case and diacritics.
104 | *
105 | * @param string $haystack The string to search in.
106 | * @param string $needle The string to search for.
107 | * @return bool Returns true if the needle is found in the haystack, false otherwise.
108 | */
109 | public static function containsIgnoreCaseAndDiacritics(string $haystack, string $needle): bool
110 | {
111 | $haystack = self::removeDiacritics(mb_strtolower($haystack, 'UTF-8'));
112 | $needle = self::removeDiacritics(mb_strtolower($needle, 'UTF-8'));
113 |
114 | return mb_strpos($haystack, $needle) !== false;
115 | }
116 |
117 | /**
118 | * Cleans a slug from extensions.
119 | *
120 | * @param string $slug the string to sanitize
121 | *
122 | * @return string the sanitized string
123 | */
124 | public static function sanitize(string $slug): string
125 | {
126 | $slug = preg_replace('/\.txt$/', '', $slug);
127 |
128 | if (null === $slug) {
129 | throw new \RuntimeException('Failed to sanitize slug.');
130 | }
131 |
132 | return rtrim($slug, '/');
133 | }
134 |
135 | /**
136 | * Converts escaped newline characters to actual newlines in the provided text.
137 | *
138 | * @param string $text the text to process
139 | *
140 | * @return string the text with escaped newlines converted to actual newlines
141 | */
142 | public static function sanitizeText(string $text): string
143 | {
144 | return str_replace('\\n', "\n", $text);
145 | }
146 |
147 | /**
148 | * Formats a URL based on the given slug and configuration settings.
149 | *
150 | * If the configuration 'show_urls' is set to 'Full', it returns the URL including the domain.
151 | * Otherwise, it returns a relative URL.
152 | *
153 | * @param string $slug the slug part of the URL to format
154 | *
155 | * @return string the formatted URL
156 | */
157 | public static function formatUrl(string $slug): string
158 | {
159 | return ShowUrls::FULL === Config::get()->showUrls ? Config::get()->url.'/'.$slug.'/' : '/'.$slug;
160 | }
161 |
162 | /**
163 | * Extracts the category name from a provided string if it matches the RSS format.
164 | *
165 | * @param string $route the input string, typically part of a URL
166 | *
167 | * @return null|string returns the category name if the pattern matches, or null if it does not
168 | */
169 | public static function extractCategoryFromRSS(string $route): ?string
170 | {
171 | if (preg_match('#^rss/([\w-]+)$#', $route, $matches)) {
172 | return $matches[1];
173 | }
174 |
175 | return null;
176 | }
177 |
178 | /**
179 | * Extracts and validates the date from a path.
180 | *
181 | * This method processes a date path from URL and returns a DateTimeImmutable object
182 | * if the format is valid and the date is logically correct. Supports formats: yyyy/mm/dd, yyyy/mm, or yyyy.
183 | *
184 | * @param string $datePath the date path from the URL
185 | *
186 | * @return array with the date and precision
187 | */
188 | public static function extractDateFromPath(string $datePath): array
189 | {
190 | $datePath = trim($datePath, '/');
191 | $parts = explode('/', $datePath);
192 | $format = '';
193 | $precision = '';
194 |
195 | switch (\count($parts)) {
196 | case 1:
197 | if (!is_numeric($parts[0]) || strlen($parts[0]) !== 4 || $parts[0] < 1900 || $parts[0] > 2100) {
198 | return [null, ''];
199 | }
200 | $format = 'Y';
201 | $precision = 'year';
202 | break;
203 |
204 | case 2:
205 | if (!is_numeric($parts[0]) || strlen($parts[0]) !== 4 || $parts[0] < 1900 || $parts[0] > 2100) {
206 | return [null, ''];
207 | }
208 | if (!is_numeric($parts[1]) || $parts[1] < 1 || $parts[1] > 12) {
209 | return [null, ''];
210 | }
211 | $format = 'Y/m';
212 | $precision = 'month';
213 | break;
214 |
215 | case 3:
216 | if (!is_numeric($parts[0]) || strlen($parts[0]) !== 4 || $parts[0] < 1900 || $parts[0] > 2100) {
217 | return [null, ''];
218 | }
219 |
220 | $year = (int)$parts[0];
221 | $month = (int)$parts[1];
222 |
223 | if ($month < 1 || $month > 12) {
224 | return [null, ''];
225 | }
226 |
227 | $day = (int)$parts[2];
228 |
229 | if (function_exists('cal_days_in_month')) {
230 | $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
231 | } else {
232 | $daysInMonth = date('t', mktime(0, 0, 0, $month, 1, $year));
233 | }
234 |
235 | if ($day < 1 || $day > $daysInMonth) {
236 | return [null, ''];
237 | }
238 |
239 | $format = 'Y/m/d';
240 | $precision = 'day';
241 | break;
242 |
243 | default:
244 | return [null, ''];
245 | }
246 |
247 | $date = \DateTimeImmutable::createFromFormat($format, $datePath);
248 |
249 | if ($date === false) {
250 | return [null, ''];
251 | }
252 |
253 | $formattedDate = $date->format($format);
254 | if ($formattedDate !== $datePath) {
255 | return [null, ''];
256 | }
257 |
258 | return [$date, $precision];
259 | }
260 |
261 | /**
262 | * Capitalizes the provided text.
263 | *
264 | * @param string $text the text to possibly capitalize
265 | *
266 | * @return string the processed text, capitalized if the setting is enabled
267 | */
268 | public static function capitalizeText(string $text): string
269 | {
270 | if (Config::get()->capitalizeTitles) {
271 | return mb_strtoupper($text, 'UTF-8');
272 | }
273 |
274 | return $text;
275 | }
276 |
277 | /**
278 | * Beautifies the provided text by applying several transformations.
279 | *
280 | * @param string $text the text to beautify
281 | *
282 | * @return string the beautified text
283 | */
284 | public static function beautifyText(string $text): string
285 | {
286 | $prefixLength = Config::get()->prefixLength + 2;
287 | $prefixPattern = str_repeat(' ', $prefixLength);
288 |
289 | $text = preg_replace('/"([^"]*)"/', '“$1”', $text);
290 | $text = str_replace(' - ', ' — ', $text);
291 | $text = str_replace(' -', ' —', $text);
292 | $text = str_replace("'", "’", $text);
293 | $text = str_replace(['***', '* * *'], '⁂', $text);
294 |
295 | $lines = explode("\n", $text);
296 | foreach ($lines as &$line) {
297 | if (strpos($line, '-') === 0) {
298 | $line = '—' . substr($line, 1);
299 | }
300 | }
301 | $text = implode("\n", $lines);
302 |
303 | return $text;
304 | }
305 | }
306 |
--------------------------------------------------------------------------------
/src/Utils/TextUtils.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | use Weblog\Config;
27 | use Weblog\Model\Enum\Beautify;
28 |
29 | final class TextUtils
30 | {
31 | /**
32 | * Centers text within the configured line width.
33 | *
34 | * @param string $text the text to be centered
35 | *
36 | * @return string the centered text
37 | */
38 | public static function centerText(string $text): string
39 | {
40 | $lineWidth = Config::get()->lineWidth;
41 | $leftPadding = ($lineWidth - mb_strlen($text)) / 2;
42 |
43 | if ($leftPadding < 0) {
44 | return $text;
45 | }
46 |
47 | return str_repeat(' ', (int) floor($leftPadding)).$text;
48 | }
49 |
50 | /**
51 | * Formats a quote block.
52 | *
53 | * @param string $text The text to be formatted as a quote.
54 | *
55 | * @return string The formatted quote.
56 | */
57 | public static function formatQuote(string $text): string
58 | {
59 | $text = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', $text);
60 |
61 | $lines = explode("\n", $text);
62 | $formattedText = '';
63 | $insideQuote = false;
64 | $quoteContent = '';
65 | $maxWidth = Validator::isMobileDevice() ? 30 : 56;
66 | $isSingleQuote = false;
67 |
68 | foreach ($lines as $line) {
69 | $trimmedLine = ltrim($line);
70 |
71 | if (str_starts_with($trimmedLine, '>')) {
72 | if (!$insideQuote) {
73 | $insideQuote = true;
74 | $quoteContent .= ltrim(substr($trimmedLine, 1));
75 | } else {
76 | $quoteContent .= "\n" . ltrim(substr($trimmedLine, 1));
77 | }
78 | } else {
79 | if ($insideQuote) {
80 | $insideQuote = false;
81 |
82 | $quoteLines = explode("\n", trim($quoteContent));
83 | if (count($quoteLines) === 1) {
84 | $singleQuote = trim($quoteLines[0]);
85 | $isSingleQuote = true;
86 | if (mb_strlen($singleQuote) > $maxWidth) {
87 | $wrappedLines = explode("\n", wordwrap($singleQuote, $maxWidth));
88 | $centeredQuote = "";
89 | foreach ($wrappedLines as $wrappedLine) {
90 | if (!Validator::isMobileDevice()) {
91 | $centeredQuote .= TextUtils::centerText($wrappedLine) . "\n";
92 | } else {
93 | $centeredQuote .= " " . TextUtils::centerText($wrappedLine) . "\n";
94 | }
95 | }
96 | $quoteContent = $centeredQuote;
97 | } else {
98 | $quoteContent = TextUtils::centerText($singleQuote);
99 | }
100 | } else {
101 | if (!Validator::isMobileDevice()) {
102 | $quoteContent = self::formatQuoteText($quoteContent);
103 | } else {
104 | $quoteContent = " " . self::formatQuoteText($quoteContent);
105 | }
106 | }
107 |
108 | $formattedText .= "\n" . $quoteContent . "\n";
109 | $quoteContent = '';
110 | }
111 | $formattedText .= self::formatParagraph($line) . "\n";
112 | }
113 | }
114 |
115 | if ($insideQuote) {
116 | $quoteLines = explode("\n", trim($quoteContent));
117 | if (count($quoteLines) === 1) {
118 | $singleQuote = trim($quoteLines[0]);
119 | $isSingleQuote = true;
120 | if (mb_strlen($singleQuote) > $maxWidth) {
121 | $wrappedLines = explode("\n", wordwrap($singleQuote, $maxWidth));
122 | $centeredQuote = "";
123 | foreach ($wrappedLines as $wrappedLine) {
124 | if (!Validator::isMobileDevice()) {
125 | $centeredQuote .= TextUtils::centerText($wrappedLine) . "\n";
126 | } else {
127 | $centeredQuote .= " " . TextUtils::centerText($wrappedLine) . "\n";
128 | }
129 | }
130 | $quoteContent = $centeredQuote;
131 | } else {
132 | if (!Validator::isMobileDevice()) {
133 | $quoteContent = TextUtils::centerText($singleQuote);
134 | } else {
135 | $quoteContent = " " . TextUtils::centerText($singleQuote);
136 | }
137 | }
138 | } else {
139 | $quoteContent = self::formatQuoteText($quoteContent);
140 | }
141 |
142 | $formattedText .= $quoteContent;
143 | }
144 |
145 | $formattedText = preg_replace(
146 | '/[\x{202F}\x{00A0}]/u',
147 | ' ',
148 | $formattedText
149 | );
150 |
151 |
152 | if ($isSingleQuote) {
153 | return "\n" . rtrim($formattedText) . "\n";
154 | } else {
155 | return rtrim($formattedText);
156 | }
157 | }
158 |
159 | /**
160 | * Formats the text of a quote block.
161 | *
162 | * @param string $text The raw text of the quote.
163 | *
164 | * @return string The formatted quote text.
165 | */
166 | public static function formatQuoteText(string $text): string
167 | {
168 | $lineWidth = Config::get()->lineWidth;
169 | $prefix = str_repeat(' ', Config::get()->prefixLength) . '| ';
170 | $lines = explode("\n", wordwrap(trim($text), $lineWidth - Config::get()->prefixLength - 4));
171 |
172 | $formattedText = '';
173 | foreach ($lines as $line) {
174 | $formattedText .= $prefix . $line . "\n";
175 | }
176 |
177 | $formattedText = preg_replace(
178 | '/[\x{202F}\x{00A0}]/u',
179 | ' ',
180 | $formattedText
181 | );
182 |
183 | return rtrim($formattedText);
184 | }
185 |
186 | /**
187 | * Formats a list block.
188 | *
189 | * @param string $text The text to be formatted as a list.
190 | *
191 | * @return string The formatted list.
192 | */
193 | public static function formatList(string $text): string
194 | {
195 | $text = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', $text);
196 |
197 | $lines = explode("\n", $text);
198 | $formattedText = '';
199 | $insideList = false;
200 | $listContent = '';
201 | $listType = '';
202 | $listItems = [];
203 | $listCount = 0;
204 |
205 | foreach ($lines as $line) {
206 | $trimmedLine = trim($line);
207 |
208 | if (preg_match('/^(\d+)\.\s/', $trimmedLine, $matches)) {
209 | $listCount++;
210 | } elseif (preg_match('/^\* /', $trimmedLine)) {
211 | $listCount++;
212 | }
213 | }
214 |
215 | foreach ($lines as $line) {
216 | $trimmedLine = trim($line);
217 |
218 | if (preg_match('/^(\d+)\.\s/', $trimmedLine, $matches)) {
219 | if ($listType === 'ul') {
220 | $formattedText .= $listContent . "\n";
221 | $listContent = '';
222 | $listType = '';
223 | }
224 |
225 | $listType = 'ol';
226 | $index = (int)$matches[1];
227 | if (!$insideList) {
228 | $insideList = true;
229 | $listContent .= self::formatListItem($line, $listType, $index, $listCount);
230 | } else {
231 | $listContent .= ($insideList && !empty($listContent) ? "\n\n" : "") . self::formatListItem($line, $listType, $index, $listCount);
232 | }
233 | } elseif (preg_match('/^\* /', $trimmedLine)) {
234 | if ($listType === 'ol') {
235 | $formattedText .= $listContent . "\n";
236 | $listContent = '';
237 | $listType = '';
238 | }
239 |
240 | $listType = 'ul';
241 | if (!$insideList) {
242 | $insideList = true;
243 | $listContent .= self::formatListItem($line, $listType, 0, $listCount);
244 | } else {
245 | $listContent .= ($insideList && !empty($listContent) ? "\n\n" : "") . self::formatListItem($line, $listType, 0, $listCount);
246 | }
247 | } else {
248 | if ($insideList) {
249 | $listContent .= "\n" . self::formatListItem($line, $listType, 0, $listCount, true);
250 | } else {
251 | $formattedText .= TextUtils::formatParagraph($trimmedLine) . "\n";
252 | }
253 | }
254 | }
255 |
256 | if ($insideList) {
257 | $formattedText .= $listContent;
258 | }
259 |
260 | $formattedText = preg_replace(
261 | '/[\x{202F}\x{00A0}]/u',
262 | ' ',
263 | $formattedText
264 | );
265 |
266 | return rtrim($formattedText);
267 | }
268 |
269 | /**
270 | * Formats a list item.
271 | *
272 | * @param string $item The text of the list item.
273 | * @param string $listType The type of the list ('ol' for ordered, 'ul' for unordered).
274 | * @param int $index The index of the list item (only for ordered lists).
275 | *
276 | * @return string The formatted list item.
277 | */
278 | public static function formatListItem(string $item, string $listType, int $index = 1, int $totalItems = 10, bool $isContinuation = false): string
279 | {
280 | $lineWidth = Config::get()->lineWidth;
281 | $prefixLength = Config::get()->prefixLength;
282 | $linePrefix = str_repeat(' ', $prefixLength);
283 |
284 | $maxDigits = strlen((string)$totalItems);
285 | $indexDigits = strlen((string)$index);
286 |
287 | if ($listType === 'ol' && !$isContinuation) {
288 | $number = $index . '.';
289 | $suffix = str_repeat(' ', $maxDigits - $indexDigits + 2);
290 | $linePrefix .= $number . $suffix;
291 | $itemText = trim(substr($item, strlen($number)));
292 | } elseif ($listType === 'ul' && !$isContinuation) {
293 | $isBeautifyEnabled = in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT]);
294 | $bullet = $item[0] === '*' ? ($isBeautifyEnabled ? '•' : '*') : ($isBeautifyEnabled ? '—' : '-');
295 | $linePrefix .= $bullet . ' ';
296 | $itemText = trim(substr($item, 2));
297 | } else {
298 | $linePrefix .= ' ';
299 | $itemText = $item;
300 | }
301 |
302 | $words = explode(' ', $itemText);
303 | $line = $linePrefix;
304 | $result = '';
305 |
306 | foreach ($words as $word) {
307 | if (mb_strlen($line . $word) > $lineWidth) {
308 | $result .= rtrim($line) . "\n";
309 | $line = str_repeat(' ', mb_strlen($linePrefix)) . $word . ' ';
310 | } else {
311 | $line .= $word . ' ';
312 | }
313 | }
314 |
315 | $result = preg_replace(
316 | '/[\x{202F}\x{00A0}]/u',
317 | ' ',
318 | $result
319 | );
320 |
321 | return $result . rtrim($line);
322 | }
323 |
324 | /**
325 | * Formats asterism text.
326 | *
327 | * @param string $text the text to be formatted
328 | *
329 | * @return string the formatted text
330 | */
331 | public static function formatAsterism(string $text): string
332 | {
333 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) {
334 | if ($text === '***' || $text === '* * *') {
335 | return "\n" . self::centerText('⁂') . "\n";
336 | }
337 | }
338 |
339 | if ($text === '***') {
340 | return "\n" . self::centerText('* * *') . "\n";
341 | }
342 |
343 | if ($text === '* * *') {
344 | return "\n" . self::centerText('* * *') . "\n";
345 | }
346 |
347 | return self::centerText($text);
348 | }
349 |
350 | /**
351 | * Formats a separator line.
352 | *
353 | * @return string the formatted separator
354 | */
355 | public static function formatSeparator(): string
356 | {
357 | $lineWidth = Config::get()->lineWidth;
358 | $prefixLength = Config::get()->prefixLength;
359 | $separator = str_repeat('—', 5);
360 |
361 | return "\n" . self::centerText(str_repeat(' ', $prefixLength) . $separator . str_repeat(' ', $prefixLength)) . "\n";
362 | }
363 |
364 | /**
365 | * Formats a paragraph to fit within the configured line width, using a specified prefix length.
366 | *
367 | * @param string $text the text of the paragraph
368 | *
369 | * @return string the formatted paragraph
370 | */
371 | public static function formatParagraph(string $text): string
372 | {
373 | $text = preg_replace(
374 | '/\x{202F}/u',
375 | '\x{00A0}',
376 | $text
377 | );
378 |
379 | if (in_array($text, ['***', '* * *'])) {
380 | return self::formatAsterism($text);
381 | }
382 |
383 | if ($text === '---') {
384 | return self::formatSeparator();
385 | }
386 |
387 | $lineWidth = Config::get()->lineWidth;
388 | $prefixLength = Config::get()->prefixLength;
389 | $linePrefix = str_repeat(' ', $prefixLength);
390 |
391 | $result = '';
392 |
393 | $breakingSpaces = '[' .
394 | '\x{0009}-\x{000D}' .
395 | '\x{0020}' .
396 | '\x{1680}' .
397 | '\x{180E}' .
398 | '\x{2000}-\x{200A}' .
399 | '\x{2028}' .
400 | '\x{2029}' .
401 | '\x{205F}' .
402 | '\x{3000}' .
403 | ']+';
404 |
405 | $tokens = preg_split('/(' . $breakingSpaces . ')/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
406 |
407 | $line = $linePrefix;
408 |
409 | foreach ($tokens as $token) {
410 | if ($token === '') {
411 | continue;
412 | }
413 |
414 | $tokenLength = mb_strlen($token);
415 |
416 | if (mb_strlen($line) + $tokenLength > $lineWidth) {
417 | if (strpos($token, '-') !== false && mb_strlen(trim($token)) <= $lineWidth - mb_strlen($linePrefix)) {
418 | $hyphenPos = mb_strpos($token, '-');
419 | $firstPart = mb_substr($token, 0, $hyphenPos + 1);
420 | $remainingPart = mb_substr($token, $hyphenPos + 1);
421 |
422 | if (mb_strlen($line) + mb_strlen($firstPart) <= $lineWidth) {
423 | $line .= $firstPart;
424 | $result .= rtrim($line) . "\n";
425 | $line = $linePrefix . $remainingPart;
426 | } else {
427 | $result .= rtrim($line) . "\n";
428 | $line = $linePrefix . $token;
429 | }
430 | } elseif (mb_strlen(trim($token)) > $lineWidth - mb_strlen($linePrefix)) {
431 | $result .= rtrim($line) . "\n";
432 | $line = $linePrefix;
433 |
434 | $token = ltrim($token);
435 | while (mb_strlen($token) > 0) {
436 | $spaceLeft = $lineWidth - mb_strlen($line);
437 | $part = mb_substr($token, 0, $spaceLeft);
438 | $token = mb_substr($token, $spaceLeft);
439 |
440 | $line .= $part;
441 |
442 | if (mb_strlen($token) > 0) {
443 | $result .= rtrim($line) . "\n";
444 | $line = $linePrefix;
445 | }
446 | }
447 | } else {
448 | $result .= rtrim($line) . "\n";
449 | $line = $linePrefix . ltrim($token);
450 | }
451 | } else {
452 | $line .= $token;
453 | }
454 | }
455 |
456 | $result .= rtrim($line);
457 |
458 | $result = preg_replace(
459 | '/[\x{202F}\x{00A0}]/u',
460 | ' ',
461 | $result
462 | );
463 |
464 | return $result;
465 | }
466 |
467 | /**
468 | * Formats a string with legal information.
469 | *
470 | * @return string the formatted paragraph
471 | */
472 | public static function formatCopyrightText(string $dateRange): string
473 | {
474 | $authorInfo = Config::get()->author->getInformation();
475 |
476 | return "Copyright (c) {$dateRange} {$authorInfo}";
477 | }
478 |
479 | /**
480 | * Formats the About section header with "About" on the left and the author's name centered.
481 | *
482 | * @return string the formatted header string
483 | */
484 | public static function formatAboutHeader(): string
485 | {
486 | $lineWidth = Config::get()->lineWidth;
487 |
488 | $leftText = Validator::isMobileDevice() ? '' : 'About';
489 | $centerText = Config::get()->author->getName();
490 | $rightText = Validator::isMobileDevice() ? '' : Config::get()->author->getLocation();
491 |
492 | $leftText = StringUtils::capitalizeText($leftText);
493 | $centerText = StringUtils::capitalizeText($centerText);
494 | $rightText = StringUtils::capitalizeText($rightText);
495 |
496 | $leftWidth = mb_strlen($leftText);
497 | $centerWidth = mb_strlen($centerText);
498 | $rightWidth = mb_strlen($rightText);
499 |
500 | $spaceToLeft = (int) (($lineWidth - $centerWidth) / 2);
501 | $spaceToRight = $lineWidth - $spaceToLeft - $centerWidth;
502 |
503 | if (Validator::isMobileDevice()) {
504 | $spaceToLeft += 1;
505 | }
506 |
507 | return "\n\n\n\n".sprintf(
508 | '%s%s%s%s%s',
509 | $leftText,
510 | str_repeat(' ', $spaceToLeft - $leftWidth),
511 | $centerText,
512 | str_repeat(' ', $spaceToRight - $rightWidth),
513 | $rightText
514 | )."\n\n\n";
515 | }
516 |
517 | /**
518 | * Formats a paragraph from the about text.
519 | *
520 | * @return string the formatted paragraph
521 | */
522 | public static function formatAboutText(): string
523 | {
524 | $aboutText = Config::get()->author->getAbout();
525 |
526 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) {
527 | $aboutText = StringUtils::beautifyText($aboutText);
528 | }
529 |
530 | $paragraphs = explode("\n", $aboutText);
531 |
532 | $formattedAboutText = '';
533 |
534 | foreach ($paragraphs as $paragraph) {
535 | $formattedParagraph = $paragraph;
536 | if (!Validator::isMobileDevice()) {
537 | $formattedParagraph = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', rtrim($paragraph));
538 | }
539 | $formattedAboutText .= self::formatParagraph($formattedParagraph ?? '')."\n";
540 | }
541 |
542 | if (Config::get()->showSeparator) {
543 | $separator = "\n\n\n".str_repeat(
544 | ' ',
545 | Validator::isMobileDevice() ? Config::get()->prefixLength : 0
546 | ).
547 | str_repeat('—', Config::get()->lineWidth - (Validator::isMobileDevice() ? Config::get()->prefixLength : 0))."\n\n\n\n\n";
548 | $formattedAboutText .= $separator;
549 | } else {
550 | $formattedAboutText .= "\n\n\n\n\n";
551 | }
552 |
553 | return $formattedAboutText;
554 | }
555 | }
556 |
--------------------------------------------------------------------------------
/src/Utils/Validator.php:
--------------------------------------------------------------------------------
1 |
7 | *
8 | * This program is free software: you can redistribute it and/or modify
9 | * it under the terms of the GNU Affero General Public License as published by
10 | * the Free Software Foundation, either version 3 of the License, or
11 | * (at your option) any later version.
12 | *
13 | * This program is distributed in the hope that it will be useful,
14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 | * GNU Affero General Public License for more details.
17 | *
18 | * You should have received a copy of the GNU Affero General Public License
19 | * along with this program. If not, see .
20 | */
21 |
22 | declare(strict_types=1);
23 |
24 | namespace Weblog\Utils;
25 |
26 | use Weblog\Config;
27 | use Weblog\Model\Entity\Post;
28 |
29 | final class Validator
30 | {
31 | /**
32 | * Determines if the date of a post matches a specific date.
33 | *
34 | * @param \DateTimeImmutable $date the date to compare
35 | * @param Post $post the post whose date is being compared
36 | *
37 | * @return bool true if the dates match, false otherwise
38 | */
39 | public static function dateMatches(\DateTimeImmutable $date, Post $post): bool
40 | {
41 | return $date->format('Y-m-d') === $post->getDate()->format('Y-m-d');
42 | }
43 |
44 | /**
45 | * Checks if the given route string represents a valid date pattern.
46 | *
47 | * @param string $route the route string to check
48 | *
49 | * @return bool returns true if the route matches a date pattern, false otherwise
50 | */
51 | public static function isDateRoute(string $route): bool
52 | {
53 | return (bool) preg_match('#^\d{4}(?:/\d{2}(?:/\d{2})?)?/?$#', $route);
54 | }
55 |
56 | /**
57 | * Determines if a file corresponds to a valid post within the specified category.
58 | *
59 | * @param \SplFileInfo $file the file to check
60 | * @param string $category the category to match against
61 | * @param string $directory The directory path
62 | *
63 | * @return bool returns true if the file is a valid post in the specified category
64 | */
65 | public static function isValidCategoryPost(\SplFileInfo $file, string $category, string $directory): bool
66 | {
67 | $filePath = str_replace('\\', '/', $file->getPathname());
68 | $directory = rtrim(str_replace('\\', '/', $directory), '/').'/';
69 |
70 | $relativePath = substr($filePath, \strlen($directory));
71 | $relativePath = ltrim($relativePath, '/');
72 |
73 | $firstDir = strstr($relativePath, '/', true) ?: $relativePath;
74 |
75 | if (('misc' === $category && (empty($firstDir) || 'misc' === $firstDir || $relativePath === $firstDir)) || $firstDir === $category) {
76 | return true;
77 | }
78 |
79 | return false;
80 | }
81 |
82 | /**
83 | * Checks if the path is a valid category folder.
84 | *
85 | * @return bool returns true if the directory exists
86 | */
87 | public static function isValidCategoryPath(string $categoryPath): bool
88 | {
89 | $weblogDir = Config::get()->weblogDir;
90 | $fullPath = $weblogDir . ('misc' !== $categoryPath ? '/'.$categoryPath : '');
91 |
92 | if (!is_dir($fullPath)) {
93 | if (!is_dir($weblogDir . '/.' . $categoryPath)) {
94 | return false;
95 | }
96 | }
97 |
98 | return true;
99 | }
100 |
101 | /**
102 | * Checks if the route is a drafts route.
103 | *
104 | * @param string $route The route string to check.
105 | * @return bool Returns true if the route is a drafts route.
106 | */
107 | public static function isDraftsRoute(string $route): bool
108 | {
109 | return preg_match('#^drafts/#', $route) === 1;
110 | }
111 |
112 | /**
113 | * Checks if the route is a search route.
114 | *
115 | * @param string $route The route string to check.
116 | * @return bool Returns true if the route is a search route.
117 | */
118 | public static function isSearchRoute(string $route): bool
119 | {
120 | return preg_match('#^search/(.+)$#', $route) === 1;
121 | }
122 |
123 | /**
124 | * Checks if the route is a selected posts route.
125 | *
126 | * @param string $route The route string to check.
127 | * @return bool Returns true if the route is a selected posts route.
128 | */
129 | public static function isSelectedRoute(string $route): bool
130 | {
131 | return preg_match('#^selected$#', $route) === 1;
132 | }
133 |
134 | /**
135 | * Checks if the current user agent is a mobile device.
136 | */
137 | public static function isMobileDevice(): bool
138 | {
139 | $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
140 |
141 | if (false === \is_string($userAgent)) {
142 | throw new \InvalidArgumentException('User agent is not a string.');
143 | }
144 |
145 | $result = preg_match('/Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i', $userAgent);
146 |
147 | if (false === $result) {
148 | throw new \RuntimeException('Failed to execute user agent match.');
149 | }
150 |
151 | return (bool) $result;
152 | }
153 | }
154 |
--------------------------------------------------------------------------------