v1.9.1: fix world map rendering and resolve eight small TODO items

World map:
- Fix zero-padded ISO numeric ids leaving 31 countries unrendered
  (Brazil, Australia, Belgium, Austria, Algeria and more)
- Fix Russia and Fiji smearing across the full map width at the antimeridian
  by unwrapping ring longitudes and drawing them at both edges
- Crop to 84N-60S, add evenodd fill rule, 174 countries rendered

Improvements:
- Logger::tail() reads backwards in chunks instead of loading the whole file
- External links get rel=noopener noreferrer in footer and Markdown content
- formatDisplayName() cleaned up and guarded against empty input
- Export statistics as CSV (Excel BOM) or JSON
- GeoIP database auto-updates when older than 35 days
- Editor shortcuts Ctrl/Cmd+S to save and Ctrl/Cmd+N for a new page
- Live search filter in the admin content browser
- Content versioning with timestamped .bak copies in content/-backups/

Also removes eight stale TODO entries that were already implemented
This commit is contained in:
2026-07-29 15:53:35 +02:00
parent 06785e9922
commit 44a1e6dc96
12 changed files with 772 additions and 281 deletions
+51 -10
View File
@@ -573,6 +573,35 @@ class CodePressCMS {
];
}
/**
* Get hosts that count as internal for external-link detection
*
* @return array List of host names
*/
private function getInternalHosts(): array
{
$hosts = [];
if (!empty($_SERVER['HTTP_HOST'])) {
$host = strtolower(preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST']));
if ($host !== '') {
$hosts[] = $host;
// Treat www and apex variants as the same site
$hosts[] = str_starts_with($host, 'www.') ? substr($host, 4) : 'www.' . $host;
}
}
$authorWebsite = $this->config['author']['website'] ?? '';
if ($authorWebsite !== '') {
$authorHost = parse_url($authorWebsite, PHP_URL_HOST);
if ($authorHost) {
$hosts[] = strtolower($authorHost);
}
}
return array_values(array_unique(array_filter($hosts)));
}
/**
* Parse Markdown content to HTML using League CommonMark
*
@@ -607,6 +636,14 @@ class CodePressCMS {
'symbol' => '',
'aria_hidden' => true,
],
'external_link' => [
'internal_hosts' => $this->getInternalHosts(),
'open_in_new_window' => true,
'html_class' => 'external-link',
'nofollow' => '',
'noopener' => 'external',
'noreferrer' => 'external',
],
];
// Create environment with extensions
@@ -617,6 +654,7 @@ class CodePressCMS {
$environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension());
$environment->addExtension(new \League\CommonMark\Extension\TaskList\TaskListExtension());
$environment->addExtension(new \League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension());
$environment->addExtension(new \League\CommonMark\Extension\ExternalLink\ExternalLinkExtension());
// Handle custom image size syntax: ![alt](url){:width="300" height="200"}
$imagePlaceholders = [];
@@ -755,30 +793,33 @@ class CodePressCMS {
* @return string Formatted display name
*/
private function formatDisplayName($filename) {
$filename = (string)$filename;
if ($filename === '') {
return '';
}
$hasLeadingDash = $filename[0] === '-';
if ($hasLeadingDash) {
$filename = substr($filename, 1);
}
$availableLangs = array_keys($this->getAvailableLanguages());
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
if (preg_match($langPattern, $filename, $matches)) {
$filename = $matches[2];
if (!empty($availableLangs)) {
$langPattern = '/^(' . implode('|', array_map('preg_quote', $availableLangs)) . ')\.(.+)$/';
if (preg_match($langPattern, $filename, $matches)) {
$filename = $matches[2];
}
}
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
$name = str_replace(['-', '_'], ' ', $filename);
$name = trim($name);
$name = trim(str_replace(['-', '_'], ' ', $filename));
$name = ucwords(strtolower($name));
$specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT'];
$lower = strtolower($name);
if (isset($specialCases[$lower])) {
$name = $specialCases[$lower];
} else {
$name = str_ireplace(array_keys($specialCases), array_values($specialCases), $name);
}
$name = $specialCases[$lower]
?? str_ireplace(array_keys($specialCases), array_values($specialCases), $name);
return ($hasLeadingDash ? '- ' : '') . $name;
}
+58 -7
View File
@@ -123,20 +123,71 @@ class Logger {
/**
* Get last N lines from log file
*
*
* Reads the file backwards in chunks so large log files never have to be
* loaded into memory in their entirety.
*
* @param int $lines Number of lines to retrieve
* @return array Log lines
* @return array Log lines (including trailing newlines, oldest first)
*/
public static function tail($lines = 100) {
if (!self::$logFile || !file_exists(self::$logFile)) {
return [];
}
$file = @file(self::$logFile);
if ($file === false) {
$lines = max(1, (int)$lines);
$handle = @fopen(self::$logFile, 'rb');
if ($handle === false) {
return [];
}
return array_slice($file, -$lines);
if (fseek($handle, 0, SEEK_END) !== 0) {
fclose($handle);
return [];
}
$fileSize = ftell($handle);
if ($fileSize === false || $fileSize === 0) {
fclose($handle);
return [];
}
$chunkSize = 8192;
$position = $fileSize;
$buffer = '';
$newlineCount = 0;
// Read backwards until we have enough newlines or reach the start
while ($position > 0 && $newlineCount <= $lines) {
$readSize = (int)min($chunkSize, $position);
$position -= $readSize;
if (fseek($handle, $position, SEEK_SET) !== 0) {
break;
}
$chunk = fread($handle, $readSize);
if ($chunk === false) {
break;
}
$buffer = $chunk . $buffer;
$newlineCount = substr_count($buffer, "\n");
}
fclose($handle);
if ($buffer === '') {
return [];
}
// Split while keeping the newline characters, matching file() behaviour
$result = preg_split('/(?<=\n)/', $buffer, -1, PREG_SPLIT_NO_EMPTY);
if ($result === false) {
return [];
}
return array_slice($result, -$lines);
}
}
+3 -3
View File
@@ -28,15 +28,15 @@
{{#cms_version}}
<span class="ms-1 cms-version text-muted">{{cms_version}}</span>
{{/cms_version}}
<a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener" class="footer-icon cms ms-1" title="{{t_powered_by}} CodePress CMS">
<a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener noreferrer" class="footer-icon cms ms-1" title="{{t_powered_by}} CodePress CMS">
<i class="bi bi-cpu"></i>
</a>
<span class="ms-1">|</span>
<a href="{{author_website}}" target="_blank" rel="noopener" class="footer-icon website" title="{{t_author_website}}">
<a href="{{author_website}}" target="_blank" rel="noopener noreferrer" class="footer-icon website" title="{{t_author_website}}">
<i class="bi bi-globe"></i>
</a>
<span class="ms-1">|</span>
<a href="{{author_git}}" target="_blank" rel="noopener" class="footer-icon git" title="{{t_author_git}}">
<a href="{{author_git}}" target="_blank" rel="noopener noreferrer" class="footer-icon git" title="{{t_author_git}}">
<i class="bi bi-git"></i>
</a>
</small>