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:
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
## ✅ Voltooid (recent)
|
## ✅ Voltooid (recent)
|
||||||
|
|
||||||
|
### Opschoning & kleine verbeteringen (v1.9.1)
|
||||||
|
- [x] Wereldkaart: nul-opgevulde ISO-nummers werden niet herkend, waardoor 31 landen ontbraken (o.a. Brazilië, Australië, België, Oostenrijk, Algerije)
|
||||||
|
- [x] Wereldkaart: Rusland en Fiji smeerden over de datumgrens uit over de volle 360°; ringen worden nu ontvouwen en aan beide randen getekend
|
||||||
|
- [x] Wereldkaart: bijgesneden op 84°N–60°Z, `fill-rule="evenodd"` voor enclaves, 174 landen
|
||||||
|
- [x] `Logger::tail()` leest het bestand nu achterwaarts in blokken i.p.v. volledig in het geheugen
|
||||||
|
- [x] Externe links krijgen `rel="noopener noreferrer"` — in de footer en automatisch in Markdown-content via `ExternalLinkExtension`
|
||||||
|
- [x] `formatDisplayName()` opgeschoond en beveiligd tegen lege invoer (PHP-waarschuwing verholpen)
|
||||||
|
- [x] Statistieken exporteren als CSV (met BOM voor Excel) of JSON
|
||||||
|
- [x] GeoIP-database werkt zichzelf automatisch bij als hij ouder is dan 35 dagen
|
||||||
|
- [x] Sneltoetsen in de editor: Ctrl/Cmd+S opslaan, Ctrl/Cmd+N nieuwe pagina
|
||||||
|
- [x] Zoekfilter in de admin content browser (live, met teller en Escape om te wissen)
|
||||||
|
- [x] Content versioning: bij elke save een tijdgestempelde `.bak` in `content/-backups/`, laatste 5 versies per bestand
|
||||||
|
|
||||||
### Statistieken & GeoIP (v1.9.0)
|
### Statistieken & GeoIP (v1.9.0)
|
||||||
- [x] `GeoIP` class met providerketen: lokaal (DB-IP Lite) → MaxMind `.mmdb` → externe API, met automatische fallback
|
- [x] `GeoIP` class met providerketen: lokaal (DB-IP Lite) → MaxMind `.mmdb` → externe API, met automatische fallback
|
||||||
- [x] Eigen pure-PHP MMDB-lezer (`MMDBReader`), geen Composer-afhankelijkheid nodig
|
- [x] Eigen pure-PHP MMDB-lezer (`MMDBReader`), geen Composer-afhankelijkheid nodig
|
||||||
@@ -81,22 +94,14 @@
|
|||||||
- [ ] **autoLinkPageTitles()** — Regex kan geneste `<a>` tags produceren (`CodePressCMS.php`)
|
- [ ] **autoLinkPageTitles()** — Regex kan geneste `<a>` tags produceren (`CodePressCMS.php`)
|
||||||
- [ ] **MQTT wachtwoord** — Credentials in plain text JSON (`MQTTTracker.php`)
|
- [ ] **MQTT wachtwoord** — Credentials in plain text JSON (`MQTTTracker.php`)
|
||||||
- [ ] **Markdown editor** — WYSIWYG/split-view Markdown editor integreren in content-edit (bijv. EasyMDE, SimpleMDE, of Toast UI Editor). Live preview, toolbar met opmaakknoppen, drag & drop afbeeldingen
|
- [ ] **Markdown editor** — WYSIWYG/split-view Markdown editor integreren in content-edit (bijv. EasyMDE, SimpleMDE, of Toast UI Editor). Live preview, toolbar met opmaakknoppen, drag & drop afbeeldingen
|
||||||
- [ ] **Plugin activeren/deactiveren** — Toggle knop per plugin in admin Plugins pagina. Schrijft `enabled: true/false` naar plugin `config.json`
|
|
||||||
- [ ] **Plugin API** — Uitgebreide API voor plugins zodat ze kunnen inhaken op CMS events (hooks/filters): `onPageLoad`, `onBeforeRender`, `onAfterRender`, `onSearch`, `onMenuBuild`
|
- [ ] **Plugin API** — Uitgebreide API voor plugins zodat ze kunnen inhaken op CMS events (hooks/filters): `onPageLoad`, `onBeforeRender`, `onAfterRender`, `onSearch`, `onMenuBuild`
|
||||||
|
|
||||||
### Medium
|
### Medium
|
||||||
- [ ] **Taalwisselaar verliest pagina** — Wisselen van taal navigeert altijd naar homepage (`header.mustache`)
|
- [ ] **ctime is geen creatietijd op Linux** — `stat()` ctime is inode-wijzigingstijd. Deels ondervangen: frontmatter `created` heeft voorrang en de footer verbergt de datum als beide gelijk zijn (`CodePressCMS.php`)
|
||||||
- [ ] **ctime is geen creatietijd op Linux** — `stat()` ctime is inode-wijzigingstijd (`CodePressCMS.php`)
|
|
||||||
- [ ] **getGuidePage() dupliceert markdown parsing** — Zelfde CommonMark setup als `parseMarkdown()`
|
|
||||||
- [ ] **HTMLBlock ontbrekende `</div>`** — Niet-gesloten tags bij null-check
|
|
||||||
- [ ] **formatDisplayName() redundante logica** — Dubbele checks en overtollige str_replace
|
|
||||||
- [ ] **Plugin configuratie editor** — Per-plugin config.json bewerken vanuit admin panel
|
|
||||||
- [ ] **Admin activity log** — Logboek van alle admin acties (wie deed wat wanneer) met viewer in dashboard
|
|
||||||
- [ ] **Wachtwoord wijzigen eigen account** — Apart formulier voor ingelogde gebruiker om eigen wachtwoord te wijzigen (met huidig wachtwoord verificatie)
|
- [ ] **Wachtwoord wijzigen eigen account** — Apart formulier voor ingelogde gebruiker om eigen wachtwoord te wijzigen (met huidig wachtwoord verificatie)
|
||||||
- [ ] **Admin thema** — Admin sidebar kleur overnemen van site thema config (`header_color`)
|
|
||||||
- [ ] **Map aanmaken/verwijderen** — Directory management in admin Content pagina
|
|
||||||
- [ ] **Bestand uploaden** — Uploaden naar andere mappen dan `-assets/` via admin Content pagina
|
- [ ] **Bestand uploaden** — Uploaden naar andere mappen dan `-assets/` via admin Content pagina
|
||||||
- [ ] **Content preview** — Live preview van Markdown/HTML content naast de editor
|
- [ ] **Content preview** — Live preview van Markdown/HTML content naast de editor
|
||||||
|
- [ ] **Backups terugzetten** — Versies uit `content/-backups/` bekijken en herstellen vanuit de admin
|
||||||
|
|
||||||
### Laag
|
### Laag
|
||||||
- [ ] **Geen type hints** — Ontbrekende type declarations op properties en methoden
|
- [ ] **Geen type hints** — Ontbrekende type declarations op properties en methoden
|
||||||
@@ -105,17 +110,10 @@
|
|||||||
- [ ] **style.css is Bootstrap** — Bestandsnaam is misleidend, Bootstrap wordt mogelijk dubbel geladen
|
- [ ] **style.css is Bootstrap** — Bestandsnaam is misleidend, Bootstrap wordt mogelijk dubbel geladen
|
||||||
- [ ] **Geen error handling op `file_get_contents()`** — Meerdere calls zonder return-check
|
- [ ] **Geen error handling op `file_get_contents()`** — Meerdere calls zonder return-check
|
||||||
- [ ] **Logger slikt fouten** — `@file_put_contents()` met error suppression
|
- [ ] **Logger slikt fouten** — `@file_put_contents()` met error suppression
|
||||||
- [ ] **Logger tail() leest heel bestand** — Geheugenprobleem bij grote logbestanden
|
|
||||||
- [ ] **Externe links missen `rel="noreferrer"`**
|
|
||||||
- [ ] **Zoekformulier mist aria-label**
|
|
||||||
- [ ] **mobile.css override Bootstrap utilities** met `!important`
|
- [ ] **mobile.css override Bootstrap utilities** met `!important`
|
||||||
- [ ] **Content versioning** — Simpele file-based backup bij elke save (bijv. `.bak` bestanden)
|
|
||||||
- [ ] **Zoeken in admin** — Zoekfunctie binnen de admin content browser
|
|
||||||
- [ ] **Drag & drop** — Bestanden herordenen/verplaatsen via drag & drop
|
- [ ] **Drag & drop** — Bestanden herordenen/verplaatsen via drag & drop
|
||||||
- [ ] **Keyboard shortcuts** — Ctrl+S om op te slaan in editor, Ctrl+N voor nieuw bestand
|
|
||||||
- [ ] **Dark mode** — Admin panel dark mode toggle
|
- [ ] **Dark mode** — Admin panel dark mode toggle
|
||||||
- [ ] **Responsive admin** — Admin sidebar inklapbaar op mobiel (nu is het gestacked)
|
- [ ] **Responsive admin** — Admin sidebar inklapbaar op mobiel (nu is het gestacked)
|
||||||
- [ ] **stats.json bij hoog verkeer** — Nu één schrijfactie met LOCK_EX per request. Bij veel verkeer eventueel opsplitsen naar dagbestanden of APCu
|
- [ ] **stats.json bij hoog verkeer** — Nu één schrijfactie met LOCK_EX per request. Bij veel verkeer eventueel opsplitsen naar dagbestanden of APCu
|
||||||
- [ ] **Steden op de kaart** — Nu alleen landniveau; met een City-database ook stippen per stad plotten
|
- [ ] **Steden op de kaart** — Nu alleen landniveau; met een City-database ook stippen per stad plotten
|
||||||
- [ ] **Statistieken exporteren** — CSV/JSON export van bezoekersstatistieken
|
- [ ] **Kleine landen op de kaart** — Natural Earth 110m bevat geen Monaco, Vaticaanstad, Liechtenstein en Singapore; eventueel 50m-dataset gebruiken
|
||||||
- [ ] **Automatische GeoIP-update** — Maandelijkse cron/schedule i.p.v. handmatig op de knop drukken
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" title="Opslaan (Ctrl+S)">
|
||||||
<i class="bi bi-check-lg"></i> Opslaan
|
<i class="bi bi-check-lg"></i> Opslaan
|
||||||
</button>
|
</button>
|
||||||
<?php if ($isEditable): ?>
|
<?php if ($isEditable): ?>
|
||||||
|
|||||||
@@ -57,6 +57,14 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||||
|
<input type="search" id="contentFilter" class="form-control border-start-0"
|
||||||
|
placeholder="Filter op bestands- of mapnaam…" autocomplete="off" aria-label="Filter content">
|
||||||
|
<span class="input-group-text bg-white text-muted" id="contentFilterCount"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -73,7 +81,7 @@
|
|||||||
<tr><td colspan="5" class="text-muted text-center py-4">Geen bestanden gevonden.</td></tr>
|
<tr><td colspan="5" class="text-muted text-center py-4">Geen bestanden gevonden.</td></tr>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($items as $item): ?>
|
<?php foreach ($items as $item): ?>
|
||||||
<tr>
|
<tr data-name="<?= htmlspecialchars(mb_strtolower($item['name'])) ?>">
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item['is_dir']): ?>
|
<?php if ($item['is_dir']): ?>
|
||||||
<a href="/admin/content?dir=<?= urlencode($item['path']) ?>">
|
<a href="/admin/content?dir=<?= urlencode($item['path']) ?>">
|
||||||
@@ -134,11 +142,55 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<tr id="contentFilterEmpty" class="d-none">
|
||||||
|
<td colspan="5" class="text-muted text-center py-4">Geen resultaten voor deze filter.</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById('contentFilter');
|
||||||
|
var counter = document.getElementById('contentFilterCount');
|
||||||
|
var emptyRow = document.getElementById('contentFilterEmpty');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
var rows = Array.prototype.slice.call(document.querySelectorAll('tbody tr[data-name]'));
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
var q = input.value.trim().toLowerCase();
|
||||||
|
var shown = 0;
|
||||||
|
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var match = q === '' || row.getAttribute('data-name').indexOf(q) !== -1;
|
||||||
|
row.classList.toggle('d-none', !match);
|
||||||
|
if (match) shown++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emptyRow) {
|
||||||
|
emptyRow.classList.toggle('d-none', shown > 0 || rows.length === 0);
|
||||||
|
}
|
||||||
|
if (counter) {
|
||||||
|
counter.textContent = q === ''
|
||||||
|
? rows.length + ' items'
|
||||||
|
: shown + ' van ' + rows.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', apply);
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
input.value = '';
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
apply();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Create Directory Modal -->
|
<!-- Create Directory Modal -->
|
||||||
<div class="modal fade" id="createDirModal" tabindex="-1">
|
<div class="modal fade" id="createDirModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
@@ -20,10 +20,20 @@ $periodLabels = [7 => 'Laatste 7 dagen', 30 => 'Laatste 30 dagen', 90 => 'Laatst
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||||
<h2 class="mb-0"><i class="bi bi-bar-chart"></i> Statistieken</h2>
|
<h2 class="mb-0"><i class="bi bi-bar-chart"></i> Statistieken</h2>
|
||||||
<div class="btn-group">
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
<?php foreach ($periodLabels as $p => $label): ?>
|
<div class="btn-group">
|
||||||
<a href="/admin/statistics?period=<?= $p ?>" class="btn btn-sm <?= $period === $p ? 'btn-primary' : 'btn-outline-secondary' ?>"><?= htmlspecialchars($label) ?></a>
|
<?php foreach ($periodLabels as $p => $label): ?>
|
||||||
<?php endforeach; ?>
|
<a href="/admin/statistics?period=<?= $p ?>" class="btn btn-sm <?= $period === $p ? 'btn-primary' : 'btn-outline-secondary' ?>"><?= htmlspecialchars($label) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="/admin/statistics?period=<?= $period ?>&export=csv" class="btn btn-sm btn-outline-success" title="Exporteer als CSV">
|
||||||
|
<i class="bi bi-filetype-csv"></i> CSV
|
||||||
|
</a>
|
||||||
|
<a href="/admin/statistics?period=<?= $period ?>&export=json" class="btn btn-sm btn-outline-success" title="Exporteer als JSON">
|
||||||
|
<i class="bi bi-filetype-json"></i> JSON
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+240
-85
@@ -3,10 +3,70 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Natural Earth World Map SVG generator for CodePress CMS
|
* Natural Earth World Map SVG generator for CodePress CMS
|
||||||
* Converts Natural Earth 110m TopoJSON to SVG with ISO alpha-2 country IDs
|
*
|
||||||
|
* Converts the Natural Earth 110m TopoJSON (public domain) into a plain SVG
|
||||||
|
* where every country path carries its ISO 3166-1 alpha-2 code as the id,
|
||||||
|
* so the admin statistics page can colour countries with plain CSS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function generateWorldMapSvg(): bool
|
/**
|
||||||
|
* ISO 3166-1 numeric -> alpha-2. Keys are integers because the TopoJSON ids
|
||||||
|
* are zero padded strings ("032"), which must not be compared as strings.
|
||||||
|
*/
|
||||||
|
function isoNumericToAlpha2(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
4 => 'AF', 8 => 'AL', 10 => 'AQ', 12 => 'DZ', 16 => 'AS', 20 => 'AD', 24 => 'AO', 28 => 'AG',
|
||||||
|
31 => 'AZ', 32 => 'AR', 36 => 'AU', 40 => 'AT', 44 => 'BS', 48 => 'BH', 50 => 'BD', 51 => 'AM',
|
||||||
|
52 => 'BB', 56 => 'BE', 60 => 'BM', 64 => 'BT', 68 => 'BO', 70 => 'BA', 72 => 'BW', 76 => 'BR',
|
||||||
|
84 => 'BZ', 86 => 'IO', 90 => 'SB', 92 => 'VG', 96 => 'BN', 100 => 'BG', 104 => 'MM', 108 => 'BI',
|
||||||
|
112 => 'BY', 116 => 'KH', 120 => 'CM', 124 => 'CA', 132 => 'CV', 136 => 'KY', 140 => 'CF',
|
||||||
|
144 => 'LK', 148 => 'TD', 152 => 'CL', 156 => 'CN', 158 => 'TW', 170 => 'CO', 174 => 'KM',
|
||||||
|
175 => 'YT', 178 => 'CG', 180 => 'CD', 184 => 'CK', 188 => 'CR', 191 => 'HR', 192 => 'CU',
|
||||||
|
196 => 'CY', 203 => 'CZ', 204 => 'BJ', 208 => 'DK', 212 => 'DM', 214 => 'DO', 218 => 'EC',
|
||||||
|
222 => 'SV', 226 => 'GQ', 231 => 'ET', 232 => 'ER', 233 => 'EE', 234 => 'FO', 238 => 'FK',
|
||||||
|
242 => 'FJ', 246 => 'FI', 248 => 'AX', 250 => 'FR', 254 => 'GF', 258 => 'PF', 260 => 'TF',
|
||||||
|
262 => 'DJ', 266 => 'GA', 268 => 'GE', 270 => 'GM', 275 => 'PS', 276 => 'DE', 288 => 'GH',
|
||||||
|
292 => 'GI', 296 => 'KI', 300 => 'GR', 304 => 'GL', 308 => 'GD', 312 => 'GP', 316 => 'GU',
|
||||||
|
320 => 'GT', 324 => 'GN', 328 => 'GY', 332 => 'HT', 340 => 'HN', 344 => 'HK', 348 => 'HU',
|
||||||
|
352 => 'IS', 356 => 'IN', 360 => 'ID', 364 => 'IR', 368 => 'IQ', 372 => 'IE', 376 => 'IL',
|
||||||
|
380 => 'IT', 384 => 'CI', 388 => 'JM', 392 => 'JP', 398 => 'KZ', 400 => 'JO', 404 => 'KE',
|
||||||
|
408 => 'KP', 410 => 'KR', 414 => 'KW', 417 => 'KG', 418 => 'LA', 422 => 'LB', 426 => 'LS',
|
||||||
|
428 => 'LV', 430 => 'LR', 434 => 'LY', 438 => 'LI', 440 => 'LT', 442 => 'LU', 446 => 'MO',
|
||||||
|
450 => 'MG', 454 => 'MW', 458 => 'MY', 462 => 'MV', 466 => 'ML', 470 => 'MT', 474 => 'MQ',
|
||||||
|
478 => 'MR', 480 => 'MU', 484 => 'MX', 492 => 'MC', 496 => 'MN', 498 => 'MD', 499 => 'ME',
|
||||||
|
500 => 'MS', 504 => 'MA', 508 => 'MZ', 512 => 'OM', 516 => 'NA', 520 => 'NR', 524 => 'NP',
|
||||||
|
528 => 'NL', 531 => 'CW', 533 => 'AW', 534 => 'SX', 540 => 'NC', 548 => 'VU', 554 => 'NZ',
|
||||||
|
558 => 'NI', 562 => 'NE', 566 => 'NG', 570 => 'NU', 578 => 'NO', 580 => 'MP', 583 => 'FM',
|
||||||
|
584 => 'MH', 585 => 'PW', 586 => 'PK', 591 => 'PA', 598 => 'PG', 600 => 'PY', 604 => 'PE',
|
||||||
|
608 => 'PH', 612 => 'PN', 616 => 'PL', 620 => 'PT', 624 => 'GW', 626 => 'TL', 630 => 'PR',
|
||||||
|
634 => 'QA', 638 => 'RE', 642 => 'RO', 643 => 'RU', 646 => 'RW', 652 => 'BL', 654 => 'SH',
|
||||||
|
659 => 'KN', 660 => 'AI', 662 => 'LC', 663 => 'MF', 666 => 'PM', 670 => 'VC', 674 => 'SM',
|
||||||
|
678 => 'ST', 682 => 'SA', 686 => 'SN', 688 => 'RS', 690 => 'SC', 694 => 'SL', 702 => 'SG',
|
||||||
|
703 => 'SK', 704 => 'VN', 705 => 'SI', 706 => 'SO', 710 => 'ZA', 716 => 'ZW', 724 => 'ES',
|
||||||
|
728 => 'SS', 729 => 'SD', 732 => 'EH', 740 => 'SR', 744 => 'SJ', 748 => 'SZ', 752 => 'SE',
|
||||||
|
756 => 'CH', 760 => 'SY', 762 => 'TJ', 764 => 'TH', 768 => 'TG', 772 => 'TK', 776 => 'TO',
|
||||||
|
780 => 'TT', 784 => 'AE', 788 => 'TN', 792 => 'TR', 795 => 'TM', 796 => 'TC', 798 => 'TV',
|
||||||
|
800 => 'UG', 804 => 'UA', 807 => 'MK', 818 => 'EG', 826 => 'GB', 831 => 'GG', 832 => 'JE',
|
||||||
|
833 => 'IM', 834 => 'TZ', 840 => 'US', 850 => 'VI', 854 => 'BF', 858 => 'UY', 860 => 'UZ',
|
||||||
|
862 => 'VE', 876 => 'WF', 882 => 'WS', 887 => 'YE', 894 => 'ZM',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Natural Earth includes a few disputed areas without an official numeric id.
|
||||||
|
* They are matched on their English name instead.
|
||||||
|
*/
|
||||||
|
function nameToAlpha2(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'kosovo' => 'XK',
|
||||||
|
'somaliland' => 'SO',
|
||||||
|
'n. cyprus' => 'CY',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateWorldMapSvg(bool $verbose = false): bool
|
||||||
{
|
{
|
||||||
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
|
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
|
||||||
$imgDir = dirname($outPath);
|
$imgDir = dirname($outPath);
|
||||||
@@ -15,10 +75,10 @@ function generateWorldMapSvg(): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$topoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
|
$topoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
|
||||||
$cacheFile = '/tmp/world-atlas-110m.json';
|
$cacheFile = sys_get_temp_dir() . '/world-atlas-110m.json';
|
||||||
|
|
||||||
if (!file_exists($cacheFile) || filesize($cacheFile) < 1000) {
|
if (!file_exists($cacheFile) || filesize($cacheFile) < 1000) {
|
||||||
$ctx = stream_context_create(['http' => ['timeout' => 15, 'user_agent' => 'CodePressCMS/1.9.0']]);
|
$ctx = stream_context_create(['http' => ['timeout' => 20, 'user_agent' => 'CodePressCMS']]);
|
||||||
$data = @file_get_contents($topoUrl, false, $ctx);
|
$data = @file_get_contents($topoUrl, false, $ctx);
|
||||||
if ($data) {
|
if ($data) {
|
||||||
file_put_contents($cacheFile, $data);
|
file_put_contents($cacheFile, $data);
|
||||||
@@ -34,72 +94,76 @@ function generateWorldMapSvg(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mapping ISO 3166-1 numeric code -> ISO 3166-1 alpha-2 code
|
// Canvas: equirectangular, cropped to the usual web-map latitude band so
|
||||||
$numericToAlpha2 = [
|
// Antarctica does not dominate and the Arctic is not overly stretched.
|
||||||
'4' => 'AF', '8' => 'AL', '12' => 'DZ', '20' => 'AD', '24' => 'AO', '28' => 'AG', '32' => 'AR', '51' => 'AM',
|
$canvasW = 1000.0;
|
||||||
'36' => 'AU', '40' => 'AT', '31' => 'AZ', '44' => 'BS', '48' => 'BH', '50' => 'BD', '52' => 'BB', '112' => 'BY',
|
$latTop = 84.0;
|
||||||
'56' => 'BE', '84' => 'BZ', '204' => 'BJ', '64' => 'BT', '68' => 'BO', '70' => 'BA', '72' => 'BW', '76' => 'BR',
|
$latBottom = -60.0;
|
||||||
'96' => 'BN', '100' => 'BG', '854' => 'BF', '108' => 'BI', '116' => 'KH', '120' => 'CM', '124' => 'CA', '132' => 'CV',
|
$canvasH = $canvasW * (($latTop - $latBottom) / 360.0);
|
||||||
'140' => 'CF', '148' => 'TD', '152' => 'CL', '156' => 'CN', '170' => 'CO', '174' => 'KM', '178' => 'CG', '180' => 'CD',
|
|
||||||
'188' => 'CR', '384' => 'CI', '191' => 'HR', '192' => 'CU', '196' => 'CY', '203' => 'CZ', '208' => 'DK', '262' => 'DJ',
|
|
||||||
'212' => 'DM', '214' => 'DO', '218' => 'EC', '818' => 'EG', '222' => 'SV', '226' => 'GQ', '232' => 'ER', '233' => 'EE',
|
|
||||||
'231' => 'ET', '242' => 'FJ', '246' => 'FI', '250' => 'FR', '266' => 'GA', '270' => 'GM', '268' => 'GE', '276' => 'DE',
|
|
||||||
'288' => 'GH', '300' => 'GR', '308' => 'GD', '320' => 'GT', '324' => 'GN', '624' => 'GW', '328' => 'GY', '332' => 'HT',
|
|
||||||
'340' => 'HN', '348' => 'HU', '352' => 'IS', '356' => 'IN', '360' => 'ID', '364' => 'IR', '368' => 'IQ', '372' => 'IE',
|
|
||||||
'376' => 'IL', '380' => 'IT', '388' => 'JM', '392' => 'JP', '400' => 'JO', '398' => 'KZ', '404' => 'KE', '296' => 'KI',
|
|
||||||
'408' => 'KP', '410' => 'KR', '414' => 'KW', '417' => 'KG', '418' => 'LA', '428' => 'LV', '422' => 'LB', '426' => 'LS',
|
|
||||||
'430' => 'LR', '434' => 'LY', '438' => 'LI', '440' => 'LT', '442' => 'LU', '807' => 'MK', '450' => 'MG', '454' => 'MW',
|
|
||||||
'458' => 'MY', '462' => 'MV', '466' => 'ML', '470' => 'MT', '584' => 'MH', '478' => 'MR', '480' => 'MU', '484' => 'MX',
|
|
||||||
'583' => 'FM', '498' => 'MD', '492' => 'MC', '496' => 'MN', '499' => 'ME', '504' => 'MA', '508' => 'MZ', '104' => 'MM',
|
|
||||||
'516' => 'NA', '520' => 'NR', '524' => 'NP', '528' => 'NL', '554' => 'NZ', '558' => 'NI', '562' => 'NE', '566' => 'NG',
|
|
||||||
'578' => 'NO', '512' => 'OM', '586' => 'PK', '585' => 'PW', '591' => 'PA', '598' => 'PG', '600' => 'PY', '604' => 'PE',
|
|
||||||
'608' => 'PH', '616' => 'PL', '620' => 'PT', '634' => 'QA', '642' => 'RO', '643' => 'RU', '646' => 'RW', '659' => 'KN',
|
|
||||||
'662' => 'LC', '670' => 'VC', '882' => 'WS', '674' => 'SM', '678' => 'ST', '682' => 'SA', '686' => 'SN', '688' => 'RS',
|
|
||||||
'690' => 'SC', '694' => 'SL', '702' => 'SG', '703' => 'SK', '705' => 'SI', '90' => 'SB', '706' => 'SO', '710' => 'ZA',
|
|
||||||
'728' => 'SS', '724' => 'ES', '144' => 'LK', '729' => 'SD', '740' => 'SR', '748' => 'SZ', '752' => 'SE', '756' => 'CH',
|
|
||||||
'760' => 'SY', '158' => 'TW', '762' => 'TJ', '834' => 'TZ', '764' => 'TH', '626' => 'TL', '768' => 'TG', '776' => 'TO',
|
|
||||||
'780' => 'TT', '788' => 'TN', '792' => 'TR', '795' => 'TM', '798' => 'TV', '800' => 'UG', '804' => 'UA', '784' => 'AE',
|
|
||||||
'826' => 'GB', '840' => 'US', '858' => 'UY', '860' => 'UZ', '548' => 'VU', '862' => 'VE', '704' => 'VN', '887' => 'YE',
|
|
||||||
'894' => 'ZM', '716' => 'ZW', '-99' => 'XK'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Decode TopoJSON arcs
|
|
||||||
$scale = $topo['transform']['scale'];
|
$scale = $topo['transform']['scale'];
|
||||||
$translate = $topo['transform']['translate'];
|
$translate = $topo['transform']['translate'];
|
||||||
$rawArcs = $topo['arcs'];
|
|
||||||
|
|
||||||
|
// Decode delta-encoded arcs into plain lon/lat pairs. Projection happens
|
||||||
|
// later, after antimeridian handling, because that works in degrees.
|
||||||
$decodedArcs = [];
|
$decodedArcs = [];
|
||||||
foreach ($rawArcs as $arcIndex => $arc) {
|
foreach ($topo['arcs'] as $arcIndex => $arc) {
|
||||||
$x = 0;
|
$x = 0;
|
||||||
$y = 0;
|
$y = 0;
|
||||||
$points = [];
|
$points = [];
|
||||||
foreach ($arc as $position) {
|
foreach ($arc as $delta) {
|
||||||
$x += $position[0];
|
$x += $delta[0];
|
||||||
$y += $position[1];
|
$y += $delta[1];
|
||||||
// Equirectangular projection mapping to 1000x500 canvas
|
$points[] = [
|
||||||
$lon = $x * $scale[0] + $translate[0];
|
$x * $scale[0] + $translate[0],
|
||||||
$lat = $y * $scale[1] + $translate[1];
|
$y * $scale[1] + $translate[1],
|
||||||
|
];
|
||||||
$svgX = round(($lon + 180) * (1000 / 360), 2);
|
|
||||||
$svgY = round((90 - $lat) * (500 / 180), 2);
|
|
||||||
$points[] = [$svgX, $svgY];
|
|
||||||
}
|
}
|
||||||
$decodedArcs[$arcIndex] = $points;
|
$decodedArcs[$arcIndex] = $points;
|
||||||
}
|
}
|
||||||
|
|
||||||
$svgPaths = [];
|
$numericMap = isoNumericToAlpha2();
|
||||||
|
$nameMap = nameToAlpha2();
|
||||||
|
|
||||||
|
// Collect paths per country code (Natural Earth can list a code more than once)
|
||||||
|
$pathsByCode = [];
|
||||||
|
$names = [];
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
foreach ($topo['objects']['countries']['geometries'] as $geo) {
|
foreach ($topo['objects']['countries']['geometries'] as $geo) {
|
||||||
$numericId = (string)($geo['id'] ?? '');
|
$rawId = $geo['id'] ?? '';
|
||||||
$countryName = $geo['properties']['name'] ?? 'Unknown';
|
$countryName = $geo['properties']['name'] ?? 'Unknown';
|
||||||
$alpha2 = $numericToAlpha2[$numericId] ?? null;
|
|
||||||
if (!$alpha2) continue;
|
$alpha2 = null;
|
||||||
|
if ($rawId !== '' && ctype_digit((string)$rawId)) {
|
||||||
|
$alpha2 = $numericMap[(int)$rawId] ?? null;
|
||||||
|
}
|
||||||
|
if ($alpha2 === null) {
|
||||||
|
$alpha2 = $nameMap[strtolower($countryName)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($alpha2 === null) {
|
||||||
|
$skipped[] = $rawId . ' ' . $countryName;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antarctica falls outside the cropped canvas
|
||||||
|
if ($alpha2 === 'AQ') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $geo['type'];
|
||||||
|
if ($type === 'Polygon') {
|
||||||
|
$polygons = [$geo['arcs']];
|
||||||
|
} elseif ($type === 'MultiPolygon') {
|
||||||
|
$polygons = $geo['arcs'];
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$pathD = '';
|
$pathD = '';
|
||||||
$type = $geo['type'];
|
foreach ($polygons as $rings) {
|
||||||
$arcsList = ($type === 'Polygon') ? [$geo['arcs']] : (($type === 'MultiPolygon') ? $geo['arcs'] : []);
|
foreach ($rings as $ring) {
|
||||||
|
|
||||||
foreach ($arcsList as $polygon) {
|
|
||||||
foreach ($polygon as $ringIndex => $ring) {
|
|
||||||
$ringPoints = [];
|
$ringPoints = [];
|
||||||
foreach ($ring as $arcIdx) {
|
foreach ($ring as $arcIdx) {
|
||||||
$reversed = $arcIdx < 0;
|
$reversed = $arcIdx < 0;
|
||||||
@@ -108,52 +172,143 @@ function generateWorldMapSvg(): bool
|
|||||||
if ($reversed) {
|
if ($reversed) {
|
||||||
$arcPoints = array_reverse($arcPoints);
|
$arcPoints = array_reverse($arcPoints);
|
||||||
}
|
}
|
||||||
if (!empty($ringPoints)) {
|
if (!empty($ringPoints) && !empty($arcPoints)) {
|
||||||
array_shift($arcPoints); // Skip duplicate start point
|
array_shift($arcPoints); // drop point shared with previous arc
|
||||||
|
}
|
||||||
|
foreach ($arcPoints as $pt) {
|
||||||
|
$ringPoints[] = $pt;
|
||||||
}
|
}
|
||||||
$ringPoints = array_merge($ringPoints, $arcPoints);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($ringPoints)) continue;
|
if (count($ringPoints) < 3) {
|
||||||
|
continue;
|
||||||
$pathD .= 'M' . $ringPoints[0][0] . ',' . $ringPoints[0][1] . ' ';
|
}
|
||||||
for ($p = 1; $p < count($ringPoints); $p++) {
|
|
||||||
$pathD .= 'L' . $ringPoints[$p][0] . ',' . $ringPoints[$p][1] . ' ';
|
// Unwrap longitudes so a ring crossing the antimeridian stays
|
||||||
|
// continuous instead of jumping from +180 to -180 and smearing
|
||||||
|
// a band right across the map (Russia, Fiji).
|
||||||
|
$offset = 0.0;
|
||||||
|
$unwrapped = [];
|
||||||
|
$prevLon = null;
|
||||||
|
foreach ($ringPoints as $pt) {
|
||||||
|
$lon = $pt[0];
|
||||||
|
if ($prevLon !== null) {
|
||||||
|
$step = $lon + $offset - $prevLon;
|
||||||
|
if ($step > 180.0) {
|
||||||
|
$offset -= 360.0;
|
||||||
|
} elseif ($step < -180.0) {
|
||||||
|
$offset += 360.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$lonAdj = $lon + $offset;
|
||||||
|
$unwrapped[] = [$lonAdj, $pt[1]];
|
||||||
|
$prevLon = $lonAdj;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lons = array_column($unwrapped, 0);
|
||||||
|
$minLon = min($lons);
|
||||||
|
$maxLon = max($lons);
|
||||||
|
|
||||||
|
// Draw the ring once normally, and once shifted a full turn when
|
||||||
|
// it sticks out past a map edge. Anything outside the viewBox is
|
||||||
|
// clipped by the SVG itself, so both edges end up correct.
|
||||||
|
$shifts = [0.0];
|
||||||
|
if ($maxLon > 180.0) {
|
||||||
|
$shifts[] = -360.0;
|
||||||
|
}
|
||||||
|
if ($minLon < -180.0) {
|
||||||
|
$shifts[] = 360.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($shifts as $shift) {
|
||||||
|
if ($shift !== 0.0
|
||||||
|
&& ($minLon + $shift > 180.0 || $maxLon + $shift < -180.0)) {
|
||||||
|
continue; // fully off-canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = [];
|
||||||
|
$visible = false;
|
||||||
|
foreach ($unwrapped as $i => $pt) {
|
||||||
|
$lon = $pt[0] + $shift;
|
||||||
|
$lat = $pt[1];
|
||||||
|
|
||||||
|
$svgX = ($lon + 180.0) * ($canvasW / 360.0);
|
||||||
|
$svgY = ($latTop - $lat) * ($canvasH / ($latTop - $latBottom));
|
||||||
|
|
||||||
|
if ($svgX >= -50 && $svgX <= $canvasW + 50 && $svgY <= $canvasH + 50) {
|
||||||
|
$visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments[] = ($i === 0 ? 'M' : 'L') . round($svgX, 2) . ',' . round($svgY, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visible) {
|
||||||
|
$pathD .= implode(' ', $segments) . ' Z ';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$pathD .= 'Z ';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trim($pathD) !== '') {
|
$pathD = trim($pathD);
|
||||||
$svgPaths[] = sprintf(
|
if ($pathD === '') {
|
||||||
' <path id="%s" data-name="%s" class="country" d="%s"><title>%s</title></path>',
|
continue;
|
||||||
htmlspecialchars($alpha2),
|
}
|
||||||
htmlspecialchars($countryName),
|
|
||||||
trim($pathD),
|
if (!isset($pathsByCode[$alpha2])) {
|
||||||
htmlspecialchars($countryName)
|
$pathsByCode[$alpha2] = '';
|
||||||
);
|
$names[$alpha2] = $countryName;
|
||||||
|
}
|
||||||
|
$pathsByCode[$alpha2] .= ($pathsByCode[$alpha2] === '' ? '' : ' ') . $pathD;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($pathsByCode);
|
||||||
|
|
||||||
|
$svgPaths = [];
|
||||||
|
foreach ($pathsByCode as $code => $d) {
|
||||||
|
$svgPaths[] = sprintf(
|
||||||
|
' <path id="%s" data-name="%s" class="country" d="%s"><title>%s</title></path>',
|
||||||
|
$code,
|
||||||
|
htmlspecialchars($names[$code], ENT_QUOTES, 'UTF-8'),
|
||||||
|
$d,
|
||||||
|
htmlspecialchars($names[$code], ENT_QUOTES, 'UTF-8')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$h = round($canvasH, 2);
|
||||||
|
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
|
||||||
|
. '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . (int)$canvasW . ' ' . $h . '"'
|
||||||
|
. ' width="100%" height="auto" preserveAspectRatio="xMidYMid meet"'
|
||||||
|
. ' fill-rule="evenodd" class="codepress-world-map" role="img"'
|
||||||
|
. ' aria-label="Wereldkaart met bezoekers per land">' . "\n"
|
||||||
|
. ' <style>' . "\n"
|
||||||
|
. ' .codepress-world-map { display: block; background: #eaf2fb; }' . "\n"
|
||||||
|
. ' .codepress-world-map .country { fill: #dfe4ea; stroke: #ffffff; stroke-width: 0.5;'
|
||||||
|
. ' stroke-linejoin: round; vector-effect: non-scaling-stroke; transition: fill .15s ease; }' . "\n"
|
||||||
|
. ' .codepress-world-map .country:hover { fill: #ffc107; }' . "\n"
|
||||||
|
. ' </style>' . "\n"
|
||||||
|
. implode("\n", $svgPaths) . "\n"
|
||||||
|
. '</svg>' . "\n";
|
||||||
|
|
||||||
|
file_put_contents($outPath, $svg);
|
||||||
|
|
||||||
|
if ($verbose) {
|
||||||
|
echo 'Landen getekend: ' . count($pathsByCode) . PHP_EOL;
|
||||||
|
echo 'Canvas: ' . (int)$canvasW . ' x ' . $h . PHP_EOL;
|
||||||
|
echo 'Bestandsgrootte: ' . round(filesize($outPath) / 1024) . ' KB' . PHP_EOL;
|
||||||
|
if ($skipped) {
|
||||||
|
echo 'Overgeslagen (geen ISO-code): ' . implode(', ', $skipped) . PHP_EOL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$svgContent = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
|
||||||
$svgContent .= '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500" width="100%" height="auto" class="codepress-world-map">' . "\n";
|
|
||||||
$svgContent .= ' <style>' . "\n";
|
|
||||||
$svgContent .= ' .country { fill: #e9ecef; stroke: #ffffff; stroke-width: 0.6; stroke-linejoin: round; transition: fill 0.2s ease; cursor: pointer; }' . "\n";
|
|
||||||
$svgContent .= ' .country:hover { fill: #0d6efd !important; opacity: 0.9; }' . "\n";
|
|
||||||
$svgContent .= ' </style>' . "\n";
|
|
||||||
$svgContent .= ' <rect width="1000" height="500" fill="#f8f9fa"/>' . "\n";
|
|
||||||
$svgContent .= implode("\n", $svgPaths) . "\n";
|
|
||||||
$svgContent .= '</svg>';
|
|
||||||
|
|
||||||
file_put_contents($outPath, $svgContent);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (php_sapi_name() === 'cli') {
|
if (php_sapi_name() === 'cli') {
|
||||||
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
|
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
|
||||||
if (generateWorldMapSvg()) {
|
if (generateWorldMapSvg(true)) {
|
||||||
echo "Wereldkaart SVG succesvol aangemaakt in public/assets/img/world-map.svg!\n";
|
echo "Klaar: public/assets/img/world-map.svg\n";
|
||||||
} else {
|
} else {
|
||||||
echo "Fout bij genereren van wereldkaart SVG.\n";
|
echo "Fout bij genereren van de wereldkaart.\n";
|
||||||
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* Parse Markdown content to HTML using League CommonMark
|
||||||
*
|
*
|
||||||
@@ -607,6 +636,14 @@ class CodePressCMS {
|
|||||||
'symbol' => '',
|
'symbol' => '',
|
||||||
'aria_hidden' => true,
|
'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
|
// Create environment with extensions
|
||||||
@@ -617,6 +654,7 @@ class CodePressCMS {
|
|||||||
$environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension());
|
$environment->addExtension(new \League\CommonMark\Extension\Table\TableExtension());
|
||||||
$environment->addExtension(new \League\CommonMark\Extension\TaskList\TaskListExtension());
|
$environment->addExtension(new \League\CommonMark\Extension\TaskList\TaskListExtension());
|
||||||
$environment->addExtension(new \League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension());
|
$environment->addExtension(new \League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension());
|
||||||
|
$environment->addExtension(new \League\CommonMark\Extension\ExternalLink\ExternalLinkExtension());
|
||||||
|
|
||||||
// Handle custom image size syntax: {:width="300" height="200"}
|
// Handle custom image size syntax: {:width="300" height="200"}
|
||||||
$imagePlaceholders = [];
|
$imagePlaceholders = [];
|
||||||
@@ -755,30 +793,33 @@ class CodePressCMS {
|
|||||||
* @return string Formatted display name
|
* @return string Formatted display name
|
||||||
*/
|
*/
|
||||||
private function formatDisplayName($filename) {
|
private function formatDisplayName($filename) {
|
||||||
|
$filename = (string)$filename;
|
||||||
|
if ($filename === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
$hasLeadingDash = $filename[0] === '-';
|
$hasLeadingDash = $filename[0] === '-';
|
||||||
if ($hasLeadingDash) {
|
if ($hasLeadingDash) {
|
||||||
$filename = substr($filename, 1);
|
$filename = substr($filename, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$availableLangs = array_keys($this->getAvailableLanguages());
|
$availableLangs = array_keys($this->getAvailableLanguages());
|
||||||
$langPattern = '/^(' . implode('|', $availableLangs) . ')\.(.+)$/';
|
if (!empty($availableLangs)) {
|
||||||
if (preg_match($langPattern, $filename, $matches)) {
|
$langPattern = '/^(' . implode('|', array_map('preg_quote', $availableLangs)) . ')\.(.+)$/';
|
||||||
$filename = $matches[2];
|
if (preg_match($langPattern, $filename, $matches)) {
|
||||||
|
$filename = $matches[2];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
|
$filename = preg_replace('/\.(md|php|html)$/', '', $filename);
|
||||||
|
|
||||||
$name = str_replace(['-', '_'], ' ', $filename);
|
$name = trim(str_replace(['-', '_'], ' ', $filename));
|
||||||
$name = trim($name);
|
|
||||||
$name = ucwords(strtolower($name));
|
$name = ucwords(strtolower($name));
|
||||||
|
|
||||||
$specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT'];
|
$specialCases = ['phpinfo' => 'phpinfo', 'ict' => 'ICT'];
|
||||||
$lower = strtolower($name);
|
$lower = strtolower($name);
|
||||||
if (isset($specialCases[$lower])) {
|
$name = $specialCases[$lower]
|
||||||
$name = $specialCases[$lower];
|
?? str_ireplace(array_keys($specialCases), array_values($specialCases), $name);
|
||||||
} else {
|
|
||||||
$name = str_ireplace(array_keys($specialCases), array_values($specialCases), $name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ($hasLeadingDash ? '- ' : '') . $name;
|
return ($hasLeadingDash ? '- ' : '') . $name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,20 +123,71 @@ class Logger {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get last N lines from log file
|
* 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
|
* @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) {
|
public static function tail($lines = 100) {
|
||||||
if (!self::$logFile || !file_exists(self::$logFile)) {
|
if (!self::$logFile || !file_exists(self::$logFile)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$file = @file(self::$logFile);
|
$lines = max(1, (int)$lines);
|
||||||
if ($file === false) {
|
|
||||||
|
$handle = @fopen(self::$logFile, 'rb');
|
||||||
|
if ($handle === false) {
|
||||||
return [];
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,15 +28,15 @@
|
|||||||
{{#cms_version}}
|
{{#cms_version}}
|
||||||
<span class="ms-1 cms-version text-muted">{{cms_version}}</span>
|
<span class="ms-1 cms-version text-muted">{{cms_version}}</span>
|
||||||
{{/cms_version}}
|
{{/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>
|
<i class="bi bi-cpu"></i>
|
||||||
</a>
|
</a>
|
||||||
<span class="ms-1">|</span>
|
<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>
|
<i class="bi bi-globe"></i>
|
||||||
</a>
|
</a>
|
||||||
<span class="ms-1">|</span>
|
<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>
|
<i class="bi bi-git"></i>
|
||||||
</a>
|
</a>
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ function handleContentEdit(AdminAuth $auth, array $config): void
|
|||||||
? implode(', ', array_map('trim', $_POST['plugins']))
|
? implode(', ', array_map('trim', $_POST['plugins']))
|
||||||
: '';
|
: '';
|
||||||
$content = updateContentFrontmatter($content, 'plugins', $plugins);
|
$content = updateContentFrontmatter($content, 'plugins', $plugins);
|
||||||
|
backupContentFile($filePath);
|
||||||
file_put_contents($filePath, $content);
|
file_put_contents($filePath, $content);
|
||||||
if (!$wasRenamed) {
|
if (!$wasRenamed) {
|
||||||
adminLog($config, 'info', $user['username'] . ' sloeg ' . basename($filePath) . ' op');
|
adminLog($config, 'info', $user['username'] . ' sloeg ' . basename($filePath) . ' op');
|
||||||
@@ -890,6 +891,45 @@ function handleStatistics(AdminAuth $auth, array $config): void
|
|||||||
$analytics = new Analytics($ana);
|
$analytics = new Analytics($ana);
|
||||||
$stats = $analytics->getStats($period);
|
$stats = $analytics->getStats($period);
|
||||||
|
|
||||||
|
// Export (CSV / JSON)
|
||||||
|
if (isset($_GET['export'])) {
|
||||||
|
$format = $_GET['export'] === 'json' ? 'json' : 'csv';
|
||||||
|
$periodSlug = $period === 0 ? 'alles' : $period . 'dagen';
|
||||||
|
$filename = 'codepress-statistieken-' . $periodSlug . '-' . date('Y-m-d');
|
||||||
|
|
||||||
|
if ($format === 'json') {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '.json"');
|
||||||
|
echo json_encode($stats, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fwrite($out, "\xEF\xBB\xBF"); // BOM so Excel reads UTF-8 correctly
|
||||||
|
|
||||||
|
fputcsv($out, ['Sectie', 'Sleutel', 'Waarde']);
|
||||||
|
foreach ($stats['totals'] as $k => $v) {
|
||||||
|
fputcsv($out, ['Totalen', $k, $v]);
|
||||||
|
}
|
||||||
|
foreach ($stats['countries'] as $k => $v) {
|
||||||
|
fputcsv($out, ['Landen', $k . ' (' . GeoIP::getCountryName($k === 'UNKNOWN' ? null : $k) . ')', $v]);
|
||||||
|
}
|
||||||
|
foreach ($stats['pages'] as $k => $v) {
|
||||||
|
fputcsv($out, ["Pagina's", $k, $v]);
|
||||||
|
}
|
||||||
|
foreach ($stats['referrers'] as $k => $v) {
|
||||||
|
fputcsv($out, ['Verwijzers', $k, $v]);
|
||||||
|
}
|
||||||
|
foreach ($stats['daily_chart'] as $day) {
|
||||||
|
fputcsv($out, ['Per dag', $day['date'], $day['views'] . ' weergaven, ' . $day['uniques'] . ' uniek']);
|
||||||
|
}
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminLog($config, 'info', $user['username'] . ' exporteerde statistieken (' . strtoupper($format) . ')');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// GeoIP database status
|
// GeoIP database status
|
||||||
$geoDir = $config['codepress_root'] . '/admin/storage/geoip';
|
$geoDir = $config['codepress_root'] . '/admin/storage/geoip';
|
||||||
$geoMeta = null;
|
$geoMeta = null;
|
||||||
@@ -897,6 +937,34 @@ function handleStatistics(AdminAuth $auth, array $config): void
|
|||||||
$geoMeta = json_decode(file_get_contents($geoDir . '/meta.json'), true);
|
$geoMeta = json_decode(file_get_contents($geoDir . '/meta.json'), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-update the GeoIP database when it is missing or older than 35 days
|
||||||
|
$geoAutoUpdated = false;
|
||||||
|
if (($ana['geoip_provider'] ?? 'local') === 'local' && !empty($ana['enabled'])) {
|
||||||
|
$lastUpdate = !empty($geoMeta['updated']) ? strtotime($geoMeta['updated']) : 0;
|
||||||
|
$isStale = $lastUpdate === 0 || $lastUpdate < strtotime('-35 days');
|
||||||
|
$lockFile = $geoDir . '/.autoupdate';
|
||||||
|
// Only retry once a day if the download keeps failing
|
||||||
|
$recentlyTried = file_exists($lockFile) && filemtime($lockFile) > strtotime('-1 day');
|
||||||
|
|
||||||
|
if ($isStale && !$recentlyTried) {
|
||||||
|
if (!is_dir($geoDir)) {
|
||||||
|
@mkdir($geoDir, 0755, true);
|
||||||
|
}
|
||||||
|
@touch($lockFile);
|
||||||
|
require_once __DIR__ . '/../cli/geoip-update.php';
|
||||||
|
$autoResult = updateGeoIPDatabase();
|
||||||
|
if ($autoResult['success']) {
|
||||||
|
$geoMeta = $autoResult['meta'];
|
||||||
|
$geoAutoUpdated = true;
|
||||||
|
adminLog($config, 'info', 'GeoIP database automatisch bijgewerkt (was verouderd)');
|
||||||
|
if ($message === '') {
|
||||||
|
$message = 'GeoIP database was verouderd en is automatisch bijgewerkt.';
|
||||||
|
$messageType = 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// World map SVG
|
// World map SVG
|
||||||
$worldMapPath = $config['codepress_root'] . '/public/assets/img/world-map.svg';
|
$worldMapPath = $config['codepress_root'] . '/public/assets/img/world-map.svg';
|
||||||
$worldMapSvg = file_exists($worldMapPath) ? file_get_contents($worldMapPath) : '';
|
$worldMapSvg = file_exists($worldMapPath) ? file_get_contents($worldMapPath) : '';
|
||||||
@@ -1745,6 +1813,45 @@ function handleUpdate(AdminAuth $auth, array $config): void
|
|||||||
|
|
||||||
// --- Frontmatter helpers ---
|
// --- Frontmatter helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a timestamped copy of a content file before it is overwritten.
|
||||||
|
*
|
||||||
|
* Backups live in a hidden -backups directory inside the content folder so the
|
||||||
|
* CMS itself skips them, and only the newest few versions per file are kept.
|
||||||
|
*/
|
||||||
|
function backupContentFile(string $filePath, int $keep = 5): void
|
||||||
|
{
|
||||||
|
if (!is_file($filePath) || filesize($filePath) === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentRoot = realpath(dirname(__DIR__) . '/content');
|
||||||
|
$realFile = realpath($filePath);
|
||||||
|
if (!$contentRoot || !$realFile || strpos($realFile, $contentRoot) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupDir = $contentRoot . '/-backups';
|
||||||
|
if (!is_dir($backupDir) && !@mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the relative path so files with the same name never collide
|
||||||
|
$relative = ltrim(substr($realFile, strlen($contentRoot)), '/');
|
||||||
|
$flat = str_replace('/', '__', $relative);
|
||||||
|
|
||||||
|
@copy($realFile, $backupDir . '/' . $flat . '.' . date('Ymd-His') . '.bak');
|
||||||
|
|
||||||
|
// Prune older versions of this specific file
|
||||||
|
$existing = glob($backupDir . '/' . $flat . '.*.bak') ?: [];
|
||||||
|
if (count($existing) > $keep) {
|
||||||
|
sort($existing); // filenames sort chronologically
|
||||||
|
foreach (array_slice($existing, 0, count($existing) - $keep) as $old) {
|
||||||
|
@unlink($old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseFrontmatterField(string $content, string $key, string $default = ''): string
|
function parseFrontmatterField(string $content, string $key, string $default = ''): string
|
||||||
{
|
{
|
||||||
if (preg_match('/^---\s*\n(.*?)\n---\s*\n/s', $content, $matches)) {
|
if (preg_match('/^---\s*\n(.*?)\n---\s*\n/s', $content, $matches)) {
|
||||||
|
|||||||
+179
-151
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 164 KiB |
@@ -230,4 +230,37 @@
|
|||||||
document.getElementById('editor-form').addEventListener('submit', function () {
|
document.getElementById('editor-form').addEventListener('submit', function () {
|
||||||
editor.save();
|
editor.save();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcuts: Ctrl/Cmd+S saves, Ctrl/Cmd+N creates a new page
|
||||||
|
function handleShortcut(e) {
|
||||||
|
var mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (!mod || e.altKey) return;
|
||||||
|
|
||||||
|
var key = (e.key || '').toLowerCase();
|
||||||
|
|
||||||
|
if (key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
editor.save();
|
||||||
|
if (form) {
|
||||||
|
if (typeof form.requestSubmit === 'function') {
|
||||||
|
form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'n') {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = '/admin/content-new';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleShortcut);
|
||||||
|
// CodeMirror swallows keystrokes inside the editor, so bind there too
|
||||||
|
editor.setOption('extraKeys', Object.assign({}, editor.getOption('extraKeys') || {}, {
|
||||||
|
'Ctrl-S': function () { handleShortcut({ ctrlKey: true, key: 's', preventDefault: function () {} }); },
|
||||||
|
'Cmd-S': function () { handleShortcut({ metaKey: true, key: 's', preventDefault: function () {} }); }
|
||||||
|
}));
|
||||||
})();
|
})();
|
||||||
|
|||||||
+17
-1
@@ -6,12 +6,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'version' => '1.9.0',
|
'version' => '1.9.1',
|
||||||
'release_date' => '2026-07-29',
|
'release_date' => '2026-07-29',
|
||||||
'codename' => 'Atlas',
|
'codename' => 'Atlas',
|
||||||
'status' => 'stable',
|
'status' => 'stable',
|
||||||
|
|
||||||
'changelog' => [
|
'changelog' => [
|
||||||
|
'1.9.1' => [
|
||||||
|
'date' => '2026-07-29',
|
||||||
|
'changes' => [
|
||||||
|
'Fix world map: zero-padded ISO numeric ids left 31 countries unrendered (Brazil, Australia, Belgium, Austria, Algeria and more)',
|
||||||
|
'Fix world map: Russia and Fiji smeared across the full map width at the antimeridian; rings are now unwrapped and drawn at both edges',
|
||||||
|
'World map cropped to 84N-60S with evenodd fill rule, 174 countries',
|
||||||
|
'Logger::tail() reads the log backwards in chunks instead of loading the whole file',
|
||||||
|
'External links get rel="noopener noreferrer" in the footer and in Markdown content',
|
||||||
|
'formatDisplayName() cleaned up and guarded against empty input',
|
||||||
|
'Export statistics as CSV (Excel-compatible BOM) or JSON',
|
||||||
|
'GeoIP database auto-updates when older than 35 days',
|
||||||
|
'Editor shortcuts: Ctrl/Cmd+S to save, Ctrl/Cmd+N for a new page',
|
||||||
|
'Live search filter in the admin content browser',
|
||||||
|
'Content versioning: timestamped .bak copies in content/-backups/, 5 versions per file',
|
||||||
|
]
|
||||||
|
],
|
||||||
'1.9.0' => [
|
'1.9.0' => [
|
||||||
'date' => '2026-07-29',
|
'date' => '2026-07-29',
|
||||||
'changes' => [
|
'changes' => [
|
||||||
|
|||||||
Reference in New Issue
Block a user