v2.6.3 (Lyra): Content multi-type handling, getAllPages() structuur, . verberg-prefix

- Content bestanden met dezelfde naam maar ander type (md/php/html) worden
  correct geserveerd: URL met extensie opent dat bestand, URL zonder extensie
  valt terug op md > php > html (resolveContentByType helper)
- Admin content editor accepteert bestanden met dezelfde naam (ander type);
  preview-knop linkt per extensie
- Frontend navigatie/directory listing/search tonen elk bestandstype apart
- getAllPages() array structuur gewijzigd naar list van
  ['path','title','type'] met type 'md'/'php'/'html'/'folder'
- Verberg-prefix logica: _ is geen verberg-prefix meer, alleen . (en -);
  admin toont wél alle . bestanden/mappen
- ContentAPI getPage()/pageExists() respecteren expliciete extensie
- Handleiding content-api.md (NL+EN) herschreven
- File-tree unificatie: _file-tree.twig + _editor-styles.twig includes
- Versie verhoogd naar 2.6.3
This commit is contained in:
2026-08-20 16:45:53 +00:00
parent 97c4d52c78
commit 6485f693dc
28 changed files with 1481 additions and 950 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
A lightweight, file-based content management system built with PHP (≥8.0).
**Version:** 2.6.1 | **License:** AGPL v3 / Commercial
**Version:** 2.6.3 | **License:** AGPL v3 / Commercial
## ✨ Features
+1 -1
View File
@@ -4,7 +4,7 @@
Een lichtgewicht, file-based content management systeem gebouwd met PHP (≥8.0).
**Versie:** 2.6.1 | **Licentie:** AGPL v3 / Commercial
**Versie:** 2.6.3 | **Licentie:** AGPL v3 / Commercial
## ✨ Features
+25 -60
View File
@@ -1,5 +1,18 @@
# TODO
## bug
- [x] Admin /content
- [x] onderwater heet de is bij Admin de content, theme en plugin de pluginFileTree overal de zelfde naam. Dit is niet logies en consistent. Als de zelfde twig gebruikt word hou dan de naamgeving algemeen.
- [x] Bij Admin/content "Content bestanden" werkt de > bij het openen van de mappen niet. Wees consistenter en compacter. Hergebuik code en maak het universeler. Deze kan gelijk zijn aan de theme en plugin .
- [x] bij media invoeren zie ik nu card's voor de images of media. Dit moet anders. Gebruik weer een filetree
- [x] Bij het editen van de content zie ik Zichtbare plugin's. Hier staan nu ook system plugin's in. Maak hier een pulldown multiselect waar ik alleen de active content plugin's ziet
- [x] Ook zie ik hier Auteur naam en auteur email, maar deze zijn niet ingevult. Deze zijn bekend omdat ik ingelocht bent.
- [x] De auteur_name en auteur_email moeten hier weg
- [x] Ook wil ik dit zie bij elke aanpassing van een pagina --- created: xx-xx-xx xx:xx:xx edited: xx-xx-xx xx:xx:xx ---
- [x] De git init is hier niet handig. Haal dit weg. Dit wordt later een systeem plugin
- [x] Houw de code consistend en compact. How in de gaten dat de functionaliteit niet omzeep geholpen wordt.
- [x] GET http://development.codepress.noorlander.info/content/_images/D_D.jpg → 403 Forbidden — opgelost: /admin/media-list content-scope gebruikt nu /-media/ prefix i.p.v. /content/
## Nice to have
- [ ] Multidomein implementeren (elk domein = eigen thema + content, één admin + site name)
- [ ] Fase 1: Config-resolutie
@@ -24,66 +37,6 @@
- [ ] AGENTS.md + config.json.example bijwerken
- [ ] Verificatie: php -l, curl met Host-header, domein-switch in admin testen
- [x] Thema editor (thema's net zo bewerkbaar maken als plugins)
- [x] Fase 1: Themabestanden bewerken
- [x] Nieuwe route theme-edit + handleThemeEdit() (spiegel van handlePluginsEdit)
- [x] scanThemeFiles() helper (geneste boom van thema-map, zoals scanPluginFiles; skipt css_compiled/)
- [x] theme-edit.twig met bestandsbrowser zijbalk + CodeMirror editor
- [x] Bewerkbare extensies: .twig, .json, .scss, .css, .js, .html, .md, .php
- [x] theme.json editor (validatie op structuur: name, template mappings, config)
- [x] Layout .twig bestanden bewerken (base.twig, full_content.twig, left_sidebar.twig, etc.)
- [x] Partials bewerken (header, navigation, footer)
- [x] Nieuw bestand aanmaken binnen thema-map (zoals plugin-editor)
- [x] Bestand opslaan met CSRF + path-traversal bescherming (realpath + prefix-check)
- [x] Fase 2: Media/assets beheer
- [x] Uploaden naar assets/ van thema (spiegel van plugins-file-upload)
- [x] Bestand verwijderen uit thema (spiegel van plugins-file-delete; theme.json beschermd)
- [x] Bestand verplaatsen binnen thema (spiegel van plugins-file-move; theme.json beschermd)
- [x] Media invoegen in editor: thema-context scant assets/ map (uitbreiding media-list endpoint met ?theme=)
- [x] Fase 3: SCSS compileren
- [x] SCSS compileren knop in theme-edit (runtime scssphp compile assets/scss/theme.scss → assets/css_compiled/theme.css)
- [x] Compile-status tonen (up-to-date/verouderd badges in theme.twig + theme-edit.twig)
- [x] Fase 4: Thema overzicht + activeren
- [x] theme.twig: Bewerken-knop per thema (link naar theme-edit)
- [x] Activeren per thema (active_theme in config.json via theme-activate route)
- [x] Themaverwijdering (alleen niet-actieve, niet-default thema's via theme-delete route)
- [x] Theme-new uitbreiden met keuze uit bestaand thema als basis (kopieer structuur, skip css_compiled/)
- [x] Fase 5: Uniformiteit + documentatie
- [x] Uniforme thema-structuur afdwingen (theme.json, assets/scss, assets/css, assets/js, assets/img, assets/fonts, partials, README.md) via createUniformThemeStructure()
- [x] Per thema README.md (gegenereerd bij aanmaken via admin)
- [x] guide/nl|en/admin-beheerder/thema-beheer.md bijgewerkt met thema-editor uitleg
- [x] theme.json schema documentatie in guide (template mappings, config, header_color, etc.)
- [x] Verificatie: php -l, curl testen (aanmaken, bewerken, opslaan, uploaden, verwijderen, verplaatsen, SCSS compileren, activeren, verwijderen), path-traversal tests, default/active theme bescherming
- [x] Content editor consistent met plugins en thema's (uniforme editor-ervaring)
- [x] Fase 1: Bestandsbrowser zijbalk voor content
- [x] Nieuwe route content-files / handleContentFiles() (spiegel van plugins-edit/theme-edit) naast de bestaande per-pagina content-edit
- [x] scanContentFiles() helper (geneste boom van content_dir, zoals scanPluginFiles/scanThemeFiles; via unified scanEditorFiles(scope))
- [x] content-files.twig met bestandsbrowser zijbalk + CodeMirror editor (herbruik de plugin/theme editor layout)
- [x] Bewerkbare extensies: .md, .php, .html (consistent met bestaande content-edit)
- [x] Nieuw bestand aanmaken binnen content_map (zoals plugin/theme-editor) met extensie-keuze + in_dir prefix
- [x] Bestand opslaan met CSRF + path-traversal bescherming (realpath + prefix-check op content_dir)
- [x] Fase 2: Media/assets beheer (uniform met plugins/thema's)
- [x] Uploaden naar content/ (spiegel van plugins-file-upload/theme-file-upload; content-file-upload route)
- [x] Bestand verwijderen uit content (spiegel van plugins-file-delete/theme-file-delete; content-file-delete route)
- [x] Bestand verplaatsen binnen content (spiegel van plugins-file-move/theme-file-move; content-file-move route)
- [x] Media invoegen in editor: content-context (media-list zonder ?plugin/?theme = content/, al aanwezig)
- [x] Fase 3: Mappen beheer (uniform met plugins/thema's structuur)
- [x] Nieuwe map aanmaken (content-dir-create-in route, zelfde UX als plugin/theme-editor nieuw-bestand, met in_dir)
- [x] Map hernoemen (content-dir-rename-in route, binnen editor zijbalk)
- [x] Map verwijderen (content-dir-delete-in route, binnen editor zijbalk, alleen lege mappen)
- [ ] Map verplaatsen (spiegel van plugins-file-move/theme-file-move voor mappen) — optioneel, niet geïmplementeerd (mappen verplaatsen is zeldzaam; hernoemen + bestand-verplaatsen dekken de use-case)
- [x] Fase 4: Uniformiteit + consistentie
- [ ] Eén herbruikbare editor-macro/template voor plugin/theme/content (voorkom dupliek Twig-macro's) — uitgesteld: de macro's verschillen per scope (plugin heeft protected-badge, theme heeft theme.json-bescherming, content heeft mappen-acties); samenvoegen zou een complexe macro met conditionals opleveren die minder leesbaar is dan drie focussen macro's
- [x] Eén herbruikbare scanFiles() helper die plugin/theme/content afhandelt (scanEditorFiles(dir, scope) + scanEditorFilesNode; scanPluginFiles/scanThemeFiles/scanContentFiles zijn dunne wrappers)
- [ ] Eén herbruikbare fileUpload/fileDelete/fileMove handler-familie met scope-parameter (plugin/theme/content) i.p.v. drie bijna-identieke handlers per type — uitgesteld: de handlers hebben scope-specifieke logica (plugin: protected-plugins check; theme: theme.json-bescherming; content: geen specifieke bescherming) die generalisatie met een configuratie-laag vereist; de huidige per-scope handlers zijn duidelijker dan één handler met veel conditionals
- [ ] Per content README.md of index.md (zoals plugins/thema's) — optioneel, alleen voor mappen; niet geïmplementeerd
- [x] guide/nl|en/admin-beheerder/content-beheer.md bijwerken met content-editor uitleg (zoals plugin-development.md en thema-beheer.md)
- [x] Fase 5: Backup/git integratie in editor
- [x] ContentBackup (ZIP/git) acties toegankelijk vanuit de content-editor zijbalk (zoals SCSS-compile knop in theme-edit)
- [x] git commit/status tonen in de editor wanneer content/ een git repo is (branch, dirty/clean badge, laatste commit)
- [x] Verificatie: php -l, curl testen (aanmaken, bewerken, opslaan, uploaden, verwijderen, verplaatsen, map aanmaken/hernoemen/verwijderen), path-traversal tests (delete/new_file/upload alle geblokkeerd), pentest, WCAG
## Voltooid ✅
- [x] Admin code en niet gebruikte mappen/bestanden opschonen
@@ -100,6 +53,18 @@
- [x] Pentest controles uitgevoerd (30/30 tests geslaagd — 0 vulnerabilities)
- [x] WCAG 2.1 AA accessibility tests (25/25 tests geslaagd — 100% compliance, test-script verbeterd met min/max checks en grep -E)
## v2.6.3 (2026-08-20) ✅
- [x] Content bestanden met dezelfde naam maar ander type (md/php/html) worden door de frontend correct geserveerd: URL met extensie opent dat specifieke bestand, URL zonder extensie valt terug op md > php > html prioriteit (resolveContentByType helper)
- [x] Admin content editor accepteert bestanden met dezelfde naam (ander type); "bestaat al" check is per extensie, melding verduidelijkt naar "Bestand met dit type bestaat al."
- [x] Admin content editor preview-knop linkt per extensie (test.php → /nl/test.php) zodat elk type individueel te bekijken blijft
- [x] Frontend navigatie, directory listing en search tonen elk bestandstype apart (extensie behouden in URL), geen samenvoeging meer
- [x] getAllPages() array structuur gewijzigd naar list van ['path' => ..., 'title' => ..., 'type' => ...] met type 'md'/'php'/'html'/'folder'; mappen krijgen eigen entry, bestanden met dezelfde naam botsen niet meer
- [x] Verberg-prefix logica gewijzigd: `_` is geen verberg-prefix meer, alleen `.` (en `-`) verbergen aan de frontend; admin toont wél alle `.` bestanden/mappen (.bak, .map) — scanEditorFilesNode content-scope toont dotfiles behalve .git/.gitkeep
- [x] ContentAPI getPage()/pageExists() respecteren expliciete extensie in $path
- [x] autoLinkPageTitles() aangepast aan nieuwe array structuur, slaat mappen over
- [x] Handleiding content-api.md (NL+EN) herschreven: echo/return mechanisme, beschikbare variabelen, nieuwe getAllPages() structuur met voorbeelden
- [x] Versie verhoogd naar 2.6.3
## v2.6.2 (2026-08-19) ✅
- [x] Pentest controles uitgevoerd (30/30)
- [x] WCAG 2.1 AA accessibility tests (25/25)
+4 -5
View File
@@ -10,8 +10,7 @@
<link rel="stylesheet" href="/plugins/Navigation/assets/css/navigation.css">
<style>
body { background-color: #f5f6fa; min-height: 100vh; }
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; min-height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; display: flex; flex-direction: column; }
.admin-sidebar .nav.flex-column:first-of-type { flex: 1; overflow-y: auto; }
.admin-sidebar { background-color: {{ sidebar_color|default('#0a369d') }}; height: 100vh; width: 240px; position: fixed; top: 0; left: 0; z-index: 100; overflow-y: auto; }
.admin-sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 0.75rem 1.25rem; border-radius: 0; }
.admin-sidebar .nav-link:hover { color: #fff; background-color: rgba(255,255,255,0.1); }
.admin-sidebar .nav-link.active { color: #fff; background-color: rgba(255,255,255,0.2); border-left: 3px solid #fff; }
@@ -24,7 +23,7 @@
.stat-card .stat-icon { font-size: 2rem; opacity: 0.7; }
.admin-user { color: rgba(255,255,255,0.6); padding: 0.75rem 1.25rem; font-size: 0.85rem; border-top: 1px solid rgba(255,255,255,0.15); }
@media (max-width: 768px) {
.admin-sidebar { width: 100%; min-height: auto; position: relative; }
.admin-sidebar { width: 100%; height: auto; position: relative; }
.admin-main { margin-left: 0; }
}
</style>
@@ -35,7 +34,7 @@
{% block extra_css %}{% endblock %}
</head>
<body>
<nav class="admin-sidebar d-flex flex-column">
<nav class="admin-sidebar" id="adminSidebar">
<div class="admin-brand">
<i class="bi bi-gear-fill"></i> {{ ta.admin_title|default('CodePress Admin') }}
</div>
@@ -134,7 +133,7 @@
{% endif %}
</ul>
<ul class="nav flex-column mt-auto mb-5">
<ul class="nav flex-column mt-3 mb-4">
{% if user_real_role == 'admin' %}
<li class="nav-item">
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#roleSwitchModal">
@@ -0,0 +1,43 @@
{# Shared editor layout + file-tree styles for the content, plugin and theme editors.
Included via {% include 'pages/_editor-styles.twig' %} inside the extra_css block. #}
<style>
.editor-layout { display: flex; gap: 1rem; align-items: stretch; }
.plugin-file-tree { width: 300px; flex: 0 0 300px; }
.editor-main { flex: 1 1 auto; min-width: 0; }
.editor-tree { list-style: none; padding-left: 0; margin: 0; }
.editor-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
.editor-tree li { padding: 0; position: relative; }
.editor-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
.editor-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
.editor-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
.editor-tree .tree-toggle.is-selected { background-color: rgba(13,110,253,.12); font-weight: 600; }
.editor-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
.editor-tree .tree-chevron { flex: 0 0 auto; }
.editor-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
.editor-tree .badge-ext { font-size: .6rem; margin-left: auto; }
.editor-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
.editor-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
.editor-tree .tree-delete-btn:hover { color: #b02a37; }
.editor-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
.editor-tree .tree-move-btn:hover { color: #0d6efd; }
.editor-tree li > .tree-link { padding-right: 3.5rem; }
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
/* Directory action buttons (new file/dir, rename, delete) — content editor only */
.tree-dir-row { position: relative; }
.tree-dir-row .tree-toggle { padding-right: 7rem; }
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
.tree-dir-actions .btn-link { color: #6c757d; }
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
.tree-dir-actions .tree-delete-btn { color: #dc3545; }
.tree-dir-actions .tree-delete-btn:hover { color: #b02a37; }
@media (max-width: 768px) {
.editor-layout { flex-direction: column; }
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
.plugin-file-tree .card-body { max-height: 260px; }
}
/* Plugin checkbox group (content editor) */
.plugin-checkbox-group { display: flex; flex-direction: column; gap: .15rem; }
.plugin-checkbox-group .form-check { margin-bottom: 0; }
.plugin-checkbox-group .form-check-label { font-size: .875rem; }
</style>
@@ -0,0 +1,191 @@
{# Shared file-tree partial used by the content, plugin and theme editors.
Renders a nested, collapsible file tree from a list of nodes produced by
scanEditorFiles()/scanContentFiles()/scanPluginFiles()/scanThemeFiles().
Required variables (passed via include with ... context):
- files: array of nodes [{name, path, is_dir, extension, size, modified, children}]
- relFile: currently selected relative file path (for active highlight + auto-expand)
- editableExts: array of editable extensions (shown as .EXT badge)
- csrf_token: CSRF token for delete forms
- treeIdPrefix: prefix for collapse element ids (e.g. 'content-tree', 'plugin-tree', 'theme-tree')
- treeRouteBase: admin route base used to build file links, e.g. '/admin/content'
- treeRouteParams: extra query string for file links, e.g. 'plugin=MyPlugin&' or 'theme=default&' or '' (content)
- treeDeleteRoute: admin route for file delete, e.g. '/admin/content-file-delete'
- treeDeleteName: optional hidden field name for delete form (e.g. 'plugin' or 'theme'); emits no field when empty
- treeDeleteValue: optional hidden field value for treeDeleteName (e.g. the plugin/theme name)
- treeMoveRoute: admin route for file move, e.g. '/admin/plugins-file-move'
- treeMoveParams: extra query string for move link, e.g. 'plugin=X&' or ''
- treeDirActions: boolean; show per-folder new-file/new-dir/rename/delete buttons (content editor only)
- treeHideActionsFor: array of relative paths where move/delete actions are hidden (e.g. ['theme.json'])
Icon maps are chosen automatically from the node extension. #}
{% macro tree(nodes, opts) %}
{% import _self as macros %}
<ul class="editor-tree">
{% for n in nodes %}
{% set isActive = n.path == opts.relFile %}
{% set containsActive = n.is_dir and opts.relFile starts with (n.path ~ '/') %}
{% if n.is_dir %}
{% set icon = 'bi-folder-fill text-warning' %}
{% elseif n.extension == 'md' %}
{% set icon = 'bi-file-text text-primary' %}
{% elseif n.extension == 'php' %}
{% set icon = 'bi-file-code text-success' %}
{% elseif n.extension == 'twig' %}
{% set icon = 'bi-filetype-tfn text-info' %}
{% elseif n.extension == 'json' %}
{% set icon = 'bi-filetype-json text-secondary' %}
{% elseif n.extension in ['css','scss'] %}
{% set icon = 'bi-filetype-css text-info' %}
{% elseif n.extension == 'js' %}
{% set icon = 'bi-filetype-js text-warning' %}
{% elseif n.extension == 'html' %}
{% set icon = 'bi-file-earmark text-info' %}
{% elseif n.extension in ['jpg','jpeg','png','gif','webp','svg'] %}
{% set icon = 'bi-file-image text-info' %}
{% elseif n.extension == 'pdf' %}
{% set icon = 'bi-file-pdf text-danger' %}
{% else %}
{% set icon = 'bi-file text-muted' %}
{% endif %}
<li>
{% if n.is_dir %}
{% set collapseId = opts.treeIdPrefix ~ '-' ~ n.path|replace({'/':'-','.':'-'}) %}
<div class="tree-dir-row">
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
data-bs-toggle="collapse"
data-bs-target="#{{ collapseId }}"
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
title="{{ n.path }}">
<i class="bi {{ containsActive ? 'bi-chevron-down' : 'bi-chevron-right' }} tree-chevron"></i>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
</button>
{% if opts.treeDirActions %}
<span class="tree-dir-actions">
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ n.path }}">
<i class="bi bi-file-earmark-plus"></i>
</button>
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ n.path }}">
<i class="bi bi-folder-plus"></i>
</button>
<a href="/admin/content-dir-rename-in?dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="/admin/content-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
<input type="hidden" name="csrf_token" value="{{ opts.csrfToken }}">
<input type="hidden" name="dir" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
</span>
{% endif %}
</div>
<div id="{{ collapseId }}"
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
{% if n.children is not empty %}
{{ macros.tree(n.children, opts) }}
{% endif %}
</div>
{% else %}
<a href="{{ opts.treeRouteBase }}?{{ opts.treeRouteParams }}file={{ n.path|url_encode }}"
class="tree-link {{ isActive ? 'is-active' : '' }}"
title="{{ n.path }}">
<span class="tree-chevron-spacer"></span>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
{% if n.extension in opts.editableExts %}
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
{% endif %}
</a>
{% if n.path not in (opts.treeHideActionsFor ?? []) %}
<a href="{{ opts.treeMoveRoute }}?{{ opts.treeMoveParams }}file={{ n.path|url_encode }}"
class="tree-move-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="{{ opts.treeDeleteRoute }}" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ opts.csrfToken }}">
{% if opts.treeDeleteName %}
<input type="hidden" name="{{ opts.treeDeleteName }}" value="{{ opts.treeDeleteValue }}">
{% endif %}
<input type="hidden" name="file" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% import _self as tree_macros %}
{# Render the tree inside the standard sidebar card. The container keeps the
generic id "editorFileTree" so shared CSS/JS selectors work everywhere. #}
<div class="plugin-file-tree">
<div class="card shadow-sm h-100">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ treeHeader|default('Bestanden') }}</span>
</div>
<div class="card-body p-2" id="editorFileTree">
{% if files is empty %}
<div class="small text-muted p-2">{{ treeEmptyText|default('Geen bestanden gevonden.') }}</div>
{% else %}
{{ tree_macros.tree(files, {
'relFile': relFile,
'editableExts': editableExts,
'csrfToken': csrf_token,
'treeIdPrefix': treeIdPrefix,
'treeRouteBase': treeRouteBase,
'treeRouteParams': treeRouteParams,
'treeDeleteRoute': treeDeleteRoute,
'treeDeleteName': treeDeleteName|default(''),
'treeDeleteValue': treeDeleteValue|default(''),
'treeMoveRoute': treeMoveRoute,
'treeMoveParams': treeMoveParams,
'treeDirActions': treeDirActions|default(false),
'treeHideActionsFor': treeHideActionsFor|default([]),
}) }}
{% endif %}
</div>
</div>
</div>
<script>
(function () {
var tree = document.getElementById('editorFileTree');
if (!tree) return;
function findToggle(collapseEl) {
var id = collapseEl.id;
if (!id) return null;
return tree.querySelector('[data-bs-target="#' + id + '"]');
}
function expand(btn) {
var icon = btn.querySelector('.tree-chevron');
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
}
function collapse(btn) {
var icon = btn.querySelector('.tree-chevron');
if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); }
}
tree.addEventListener('shown.bs.collapse', function (e) {
var btn = findToggle(e.target);
if (btn) expand(btn);
});
tree.addEventListener('hidden.bs.collapse', function (e) {
var btn = findToggle(e.target);
if (btn) collapse(btn);
});
// Set initial state for folders that are open on page load
Array.prototype.forEach.call(tree.querySelectorAll('.tree-collapse.show'), function (c) {
var btn = findToggle(c);
if (btn) expand(btn);
});
})();
</script>
+180 -70
View File
@@ -1,10 +1,13 @@
{# Reusable media modal, included by admin.twig when needs_editor is true.
The editor toolbar "media" button opens this modal. It fetches the list
of media files from /admin/media-list (JSON) and, on click, inserts a
snippet into the active CodeMirror editor at the cursor.
of media files from /admin/media-list (JSON) and renders them as a
collapsible file tree (consistent with the content/plugin/theme editors).
On click of a file node, a snippet is inserted into the active CodeMirror
editor at the cursor.
Scope:
- Default (content editor): fetches /admin/media-list
which scans content/ (URLs are prefixed with /-media/).
- Plugin editor (mediaPluginName set): fetches /admin/media-list?plugin=<name>
which scans plugins/<name>/assets/ instead of content/.
- Theme editor (mediaThemeName set): fetches /admin/media-list?theme=<name>
@@ -26,20 +29,20 @@
<span class="input-group-text bg-white"><i class="bi bi-search text-muted"></i></span>
<input type="search" id="mediaModalFilter" class="form-control" placeholder="{{ ta.media_filter|default('Filter op bestandsnaam...') }}" autocomplete="off" aria-label="{{ ta.media_filter|default('Filter') }}">
</div>
<div id="mediaModalGrid" class="row g-3">
<div class="col-12 text-center text-muted py-4" id="mediaModalLoading">
<div id="mediaModalTree" class="media-tree-wrap">
<div class="text-center text-muted py-4" id="mediaModalLoading">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">{{ ta.media_loading|default('Media laden...') }}</span>
</div>
<div class="col-12 text-center text-muted py-4 d-none" id="mediaModalEmpty">
<div class="text-center text-muted py-4 d-none" id="mediaModalEmpty">
<i class="bi bi-image display-6 d-block mb-2"></i>
{{ mediaEmptyText|default('Geen media bestanden gevonden in content/. Upload eerst bestanden via Media in het menu.') }}
</div>
<div class="col-12 text-center text-danger py-4 d-none" id="mediaModalError">
<div class="text-center text-danger py-4 d-none" id="mediaModalError">
<i class="bi bi-exclamation-triangle display-6 d-block mb-2"></i>
<span id="mediaModalErrorText"></span>
</div>
{# Filled by JS #}
{# Tree populated by JS #}
</div>
</div>
<div class="modal-footer">
@@ -49,6 +52,22 @@
</div>
</div>
<style>
/* Media modal tree — self-contained so the modal works on any editor page. */
.media-tree-wrap { max-height: 60vh; overflow-y: auto; }
.media-tree { list-style: none; padding-left: 0; margin: 0; }
.media-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
.media-tree li { padding: 0; position: relative; }
.media-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; cursor: pointer; }
.media-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
.media-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
.media-tree .tree-chevron { flex: 0 0 auto; }
.media-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
.media-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.media-tree .badge-ext { font-size: .6rem; margin-left: auto; }
.media-tree .media-thumb { width: 1.1rem; height: 1.1rem; object-fit: cover; flex: 0 0 auto; border-radius: .2rem; }
</style>
<script>
(function () {
'use strict';
@@ -76,7 +95,6 @@
}
return '<a href="' + url + '">' + name + '</a>';
}
// css, scss, js, json, etc.: just insert the URL
return url;
}
@@ -85,86 +103,167 @@
return map[ext] || 'markdown';
}
function renderGrid() {
var grid = document.getElementById('mediaModalGrid');
if (!grid) return;
// Clear previous items (keep loading/empty/error placeholders)
Array.prototype.forEach.call(grid.querySelectorAll('[data-media-item]'), function (el) { el.remove(); });
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
var shown = 0;
items.forEach(function (item) {
if (q && item.name.toLowerCase().indexOf(q) === -1) return;
shown++;
var col = document.createElement('div');
col.className = 'col-6 col-md-4 col-lg-3';
col.setAttribute('data-media-item', '1');
var card = document.createElement('div');
card.className = 'card shadow-sm h-100';
card.style.cursor = 'pointer';
card.title = item.name + ' (' + item.size + ')';
if (item.is_image) {
var img = document.createElement('img');
img.src = item.url;
img.className = 'card-img-top';
img.style.objectFit = 'cover';
img.style.height = '120px';
img.loading = 'lazy';
img.alt = item.name;
card.appendChild(img);
} else {
var ph = document.createElement('div');
ph.className = 'card-img-top d-flex align-items-center justify-content-center bg-light';
ph.style.height = '120px';
var icon = document.createElement('i');
icon.className = 'bi bi-file-earmark-play display-6 text-muted';
ph.appendChild(icon);
card.appendChild(ph);
function iconFor(ext, isImage) {
if (isImage) return 'bi-file-image text-info';
if (ext === 'pdf') return 'bi-file-pdf text-danger';
if (['mp4', 'webm', 'mov', 'ogg', 'avi'].indexOf(ext) !== -1) return 'bi-file-play text-info';
if (['mp3', 'wav'].indexOf(ext) !== -1) return 'bi-file-music text-info';
return 'bi-file text-muted';
}
var body = document.createElement('div');
body.className = 'card-body p-2';
var label = document.createElement('p');
label.className = 'card-text small text-muted text-truncate mb-0';
label.textContent = item.name;
body.appendChild(label);
card.appendChild(body);
// Build a nested tree from a flat list of {path, name, ...} items.
function buildTree(flat) {
var root = { name: '', path: '', children: {}, isDir: true };
flat.forEach(function (item) {
var parts = item.path.split('/');
var node = root;
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
if (part === '') continue;
var isLast = i === parts.length - 1;
if (!node.children[part]) {
node.children[part] = {
name: part, path: parts.slice(0, i + 1).join('/'),
children: {}, isDir: !isLast
};
}
if (isLast) {
node.children[part].item = item;
node.children[part].isDir = false;
}
node = node.children[part];
}
});
// Convert children maps to sorted arrays (dirs first, then alpha)
function finalize(n) {
var arr = Object.keys(n.children).map(function (k) { return n.children[k]; });
arr.sort(function (a, b) {
if (a.isDir && !b.isDir) return -1;
if (!a.isDir && b.isDir) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
n.childArr = arr;
arr.forEach(finalize);
return n;
}
finalize(root);
return root;
}
card.addEventListener('click', function () {
function matchesFilter(node, q) {
if (!q) return true;
if (node.item && node.item.name.toLowerCase().indexOf(q) !== -1) return true;
if (node.childArr) {
for (var i = 0; i < node.childArr.length; i++) {
if (matchesFilter(node.childArr[i], q)) return true;
}
}
return false;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
var idCounter = 0;
function renderNode(node, container, q) {
var ul = document.createElement('ul');
ul.className = 'media-tree';
node.childArr.forEach(function (child) {
if (!matchesFilter(child, q)) return;
var li = document.createElement('li');
if (child.isDir) {
var collapseId = 'media-tree-' + (++idCounter);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tree-link tree-toggle btn-toggle collapsed';
btn.setAttribute('data-bs-toggle', 'collapse');
btn.setAttribute('data-bs-target', '#' + collapseId);
btn.setAttribute('aria-expanded', 'false');
btn.title = child.path;
btn.innerHTML = '<i class="bi bi-chevron-right tree-chevron"></i>' +
'<i class="bi bi-folder-fill text-warning"></i>' +
'<span class="text-truncate">' + escapeHtml(child.name) + '</span>';
li.appendChild(btn);
var wrap = document.createElement('div');
wrap.id = collapseId;
wrap.className = 'tree-collapse collapse';
renderNode(child, wrap, q);
li.appendChild(wrap);
} else {
var item = child.item;
var a = document.createElement('a');
a.className = 'tree-link';
a.title = item.path + ' (' + item.size + ')';
var thumb = '';
if (item.is_image) {
thumb = '<img class="media-thumb" src="' + item.url + '" alt="" loading="lazy">';
} else {
thumb = '<i class="bi ' + iconFor(item.extension, item.is_image) + '"></i>';
}
a.innerHTML = '<span class="tree-chevron-spacer"></span>' + thumb +
'<span class="text-truncate">' + escapeHtml(item.name) + '</span>' +
'<span class="badge bg-secondary badge-ext">' + item.extension.toUpperCase() + '</span>';
a.addEventListener('click', function (e) {
e.preventDefault();
insertItem(item);
});
li.appendChild(a);
}
ul.appendChild(li);
});
container.appendChild(ul);
}
function insertItem(item) {
var editor = window.codeMirrorEditor;
if (!editor) { return; }
if (!editor) return;
var ext = (document.getElementById('editor-textarea') || {}).dataset;
var fileExt = (ext && ext.ext) ? ext.ext : 'md';
var mode = modeForExt(fileExt);
var snippet = formatSnippet(item.url, item.name, item.extension, mode);
editor.replaceSelection(snippet);
editor.focus();
// Close the modal
var modalEl = document.getElementById('mediaModal');
if (window.bootstrap && modalEl) {
var inst = bootstrap.Modal.getInstance(modalEl);
if (inst) inst.hide();
}
});
}
col.appendChild(card);
grid.appendChild(col);
});
// Toggle empty placeholder
var empty = document.getElementById('mediaModalEmpty');
function renderTree() {
var wrap = document.getElementById('mediaModalTree');
if (!wrap) return;
// Clear previous tree (keep placeholders)
Array.prototype.forEach.call(wrap.querySelectorAll('.media-tree'), function (el) { el.remove(); });
var q = (document.getElementById('mediaModalFilter').value || '').trim().toLowerCase();
var tree = buildTree(items);
var shown = tree.childArr.length;
if (shown > 0) {
// When filtering, auto-expand all dirs by rendering without collapsed class
renderNode(tree, wrap, q);
}
var loading = document.getElementById('mediaModalLoading');
var empty = document.getElementById('mediaModalEmpty');
if (loading) loading.classList.add('d-none');
if (empty) empty.classList.toggle('d-none', shown > 0);
// When filtering, expand all folders for visibility
if (q) {
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-collapse'), function (el) { el.classList.add('show'); });
Array.prototype.forEach.call(wrap.querySelectorAll('.tree-toggle.btn-toggle'), function (el) {
el.classList.remove('collapsed');
el.setAttribute('aria-expanded', 'true');
var icon = el.querySelector('.tree-chevron');
if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); }
});
}
}
function load() {
if (loaded) { renderGrid(); return; }
if (loaded) { renderTree(); return; }
var plugin = {{ mediaPluginName|default('')|json_encode|raw }};
var theme = {{ mediaThemeName|default('')|json_encode|raw }};
var url = '/admin/media-list';
@@ -178,7 +277,7 @@
.then(function (data) {
items = Array.isArray(data) ? data : [];
loaded = true;
renderGrid();
renderTree();
})
.catch(function (err) {
var loading = document.getElementById('mediaModalLoading');
@@ -190,15 +289,26 @@
});
}
// Load when the modal is first shown
var modalEl = document.getElementById('mediaModal');
if (modalEl) {
modalEl.addEventListener('show.bs.modal', load);
}
// Filter as the user types
var filter = document.getElementById('mediaModalFilter');
if (filter) {
filter.addEventListener('input', renderGrid);
filter.addEventListener('input', renderTree);
}
// Chevron rotation: swap icon class between chevron-right and chevron-down
var mediaTreeWrap = document.getElementById('mediaModalTree');
if (mediaTreeWrap) {
mediaTreeWrap.addEventListener('shown.bs.collapse', function (e) {
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-right'); icon.classList.add('bi-chevron-down'); } }
});
mediaTreeWrap.addEventListener('hidden.bs.collapse', function (e) {
var btn = mediaTreeWrap.querySelector('[data-bs-target="#' + e.target.id + '"]');
if (btn) { var icon = btn.querySelector('.tree-chevron'); if (icon) { icon.classList.remove('bi-chevron-down'); icon.classList.add('bi-chevron-right'); } }
});
}
})();
</script>
@@ -17,7 +17,7 @@
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.rename|default('Hernoemen') }}
</button>
<a href="/admin/content-list?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
</form>
{% endblock %}
@@ -2,6 +2,10 @@
{% block title %}{{ fileName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{% block extra_css %}
{% include 'pages/_editor-styles.twig' %}
{% endblock %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-pencil"></i> {{ fileName }}</h2>
@@ -11,12 +15,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="row mb-3">
<div class="col-md-4">
<label for="filename" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="filename" name="filename" value="{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" required>
<span class="input-group-text">.{{ fileExt }}</span>
</div>
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
<label class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
<p class="form-control-plaintext mb-0"><code>{{ fileName }}</code></p>
</div>
{% if isEditable %}
<div class="col-md-4">
@@ -29,31 +29,29 @@
</div>
{% if availablePlugins is not empty %}
<div class="col-md-4">
<label class="form-label d-block">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
<div class="d-flex flex-wrap gap-1">
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
<div class="plugin-checkbox-group">
{% for plugin in availablePlugins %}
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</div>
{% if isEditable and (currentAuthorName or currentAuthorEmail or currentCreated) %}
{% if isEditable and (currentCreated or currentEdited) %}
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorName }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-envelope"></i> {{ ta.author_email|default('Auteur e-mail') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorEmail }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-calendar"></i> {{ ta.created|default('Aangemaakt') }}</label>
<label class="form-label text-muted"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
</div>
</div>
{% endif %}
{% if isEditable %}
@@ -67,11 +65,11 @@
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
{% if isEditable %}
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
</a>
{% endif %}
<a href="/admin/content-list?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
<a href="/admin/content" class="btn btn-outline-secondary" id="back-btn">{{ ta.back|default('Terug') }}</a>
</div>
</form>
</div>
@@ -80,7 +78,6 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
var backBtn = document.getElementById('back-btn');
var filenameInput = document.getElementById('filename');
var layoutSelect = document.getElementById('layout');
if (!backBtn) return;
@@ -94,10 +91,6 @@ document.addEventListener('DOMContentLoaded', function () {
backBtn.classList.add('btn-outline-danger');
}
if (filenameInput) {
filenameInput.addEventListener('input', markChanged);
}
// Update the layout value in the editor's frontmatter when the select changes
if (layoutSelect) {
layoutSelect.addEventListener('change', function () {
@@ -2,123 +2,14 @@
{% block title %}{{ ta.content_editor|default('Content editor') }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{# Recursively render the content file tree as a nested, collapsible list. #}
{% macro tree(nodes, relFile, editableExts, csrfToken) %}
{% import _self as macros %}
<ul class="plugin-tree">
{% for n in nodes %}
{% set isActive = n.path == relFile %}
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'html' ? 'bi-file-earmark text-info' : (n.extension in ['jpg','jpeg','png','gif','webp','svg'] ? 'bi-file-image text-info' : (n.extension == 'pdf' ? 'bi-file-pdf text-danger' : 'bi-file text-muted'))))) %}
<li>
{% if n.is_dir %}
{% set collapseId = 'content-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
<div class="tree-dir-row">
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
data-bs-toggle="collapse"
data-bs-target="#{{ collapseId }}"
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
title="{{ n.path }}">
<i class="bi bi-chevron-down tree-chevron"></i>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
</button>
<span class="tree-dir-actions">
<button type="button" class="btn btn-link btn-sm p-0 tree-newfile-btn" title="{{ ta.new_file|default('Nieuw bestand hier') }}" aria-label="{{ ta.new_file|default('Nieuw bestand') }}" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ n.path }}">
<i class="bi bi-file-earmark-plus"></i>
</button>
<button type="button" class="btn btn-link btn-sm p-0 tree-newdir-btn" title="{{ ta.new_folder|default('Nieuwe map') }}" aria-label="{{ ta.new_folder|default('Nieuwe map') }}" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ n.path }}">
<i class="bi bi-folder-plus"></i>
</button>
<a href="/admin/content-dir-rename-in?dir={{ n.path|url_encode }}" class="tree-rename-btn" title="{{ ta.rename|default('Hernoemen') }}" aria-label="{{ ta.rename|default('Hernoemen') }}">
<i class="bi bi-pencil"></i>
</a>
<form method="POST" action="/admin/content-dir-delete-in" class="d-inline" onsubmit="return confirm('{{ ta.confirm_delete_folder|default('Weet je zeker dat je deze map wilt verwijderen? De map moet leeg zijn.') }}')">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
<input type="hidden" name="dir" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
</span>
</div>
<div id="{{ collapseId }}"
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
{% if n.children is not empty %}
{{ macros.tree(n.children, relFile, editableExts, csrfToken) }}
{% endif %}
</div>
{% else %}
<a href="/admin/content?file={{ n.path|url_encode }}"
class="tree-link {{ isActive ? 'is-active' : '' }}"
title="{{ n.path }}">
<span class="tree-chevron-spacer"></span>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
{% if n.extension in editableExts %}
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
{% endif %}
</a>
<a href="/admin/content-file-move?file={{ n.path|url_encode }}"
class="tree-move-btn" title="{{ ta.move|default('Verplaatsen') }}" aria-label="{{ ta.move|default('Verplaatsen') }}">
<i class="bi bi-arrows-move"></i>
</a>
<form method="POST" action="/admin/content-file-delete" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
<input type="hidden" name="file" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm p-0 text-danger tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% import _self as tree_macros %}
{% block extra_css %}
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/admin/assets/css/editor.css">
{% include 'pages/_editor-styles.twig' %}
<style>
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
.plugin-file-tree { width: 300px; flex: 0 0 300px; }
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
.plugin-tree li { padding: 0; position: relative; }
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
/* Directory action buttons (new file/dir, rename, delete) */
.tree-dir-row { position: relative; }
.tree-dir-row .tree-toggle { padding-right: 7rem; }
.tree-dir-actions { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); display: flex; gap: .15rem; align-items: center; }
.tree-dir-actions .btn-link { color: #6c757d; }
.tree-dir-actions .btn-link:hover { color: #0d6efd; }
.tree-dir-actions .tree-delete-btn { color: #dc3545; }
.tree-dir-actions .tree-delete-btn:hover { color: #b02a37; }
/* Git status badge */
/* Git status badge (content editor only) */
.git-status { font-size: .75rem; }
.git-status .badge { font-weight: 400; }
@media (max-width: 768px) {
.plugin-editor-layout { flex-direction: column; }
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
.plugin-file-tree .card-body { max-height: 260px; }
}
</style>
{% endblock %}
@@ -126,58 +17,23 @@
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-pencil"></i> {{ ta.content_editor|default('Content editor') }}</h2>
<div class="d-flex gap-2 flex-wrap">
{# Fase 5: backup/git integratie in zijbalk header #}
{% if gitStatus.available %}
{# Backup integratie in zijbalk header (git wordt later een systeem plugin) #}
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
</a>
{% if gitStatus.dirty %}
<form method="POST" action="/admin/content-git-commit" class="d-inline" onsubmit="return confirm('{{ ta.git_commit_confirm|default('Wijzigingen committen naar git?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-outline-success btn-sm" title="{{ ta.git_commit|default('Git commit') }}">
<i class="bi bi-git"></i> {{ ta.git_commit|default('Commit') }}
</button>
</form>
{% endif %}
{% else %}
<form method="POST" action="/admin/content-git-init" class="d-inline" onsubmit="return confirm('{{ ta.git_init_confirm|default('Git repository initialiseren in content/?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="{{ ta.git_init|default('Git init') }}">
<i class="bi bi-git"></i> {{ ta.git_init|default('Git init') }}
</button>
</form>
<a href="/admin/content-backup" class="btn btn-outline-info btn-sm" title="{{ ta.backup|default('Backup') }}">
<i class="bi bi-archive"></i> {{ ta.backup|default('Backup') }}
</a>
{% endif %}
<button type="button" class="btn btn-outline-success btn-sm" data-bs-toggle="collapse" data-bs-target="#contentUploadForm">
<i class="bi bi-cloud-upload"></i> {{ ta.upload|default('Upload') }}
</button>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="">
<button type="button" class="btn btn-outline-primary btn-sm" id="newFileBtnTop" data-bs-toggle="modal" data-bs-target="#newFileModal" data-in-dir="{{ selectedDir|default('') }}">
<i class="bi bi-file-earmark-plus"></i> {{ ta.new_file|default('Nieuw bestand') }}
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="">
<button type="button" class="btn btn-outline-secondary btn-sm" id="newDirBtnTop" data-bs-toggle="modal" data-bs-target="#newDirModal" data-in-dir="{{ selectedDir|default('') }}">
<i class="bi bi-folder-plus"></i> {{ ta.new_folder|default('Nieuwe map') }}
</button>
<a href="/admin/content-list" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-list"></i> {{ ta.list_view|default('Lijst weergave') }}
</a>
</div>
</div>
{# Fase 5: git status badge bovenaan #}
{% if gitStatus.available %}
<div class="git-status mb-2 d-flex gap-2 align-items-center flex-wrap">
<span class="badge bg-secondary"><i class="bi bi-git"></i> {{ gitStatus.branch|default('git') }}</span>
{% if gitStatus.dirty %}
<span class="badge bg-warning text-dark">{{ ta.git_dirty|default('niet-committed wijzigingen') }}</span>
{% else %}
<span class="badge bg-success">{{ ta.git_clean|default('schone werkmap') }}</span>
{% endif %}
{% if gitStatus.lastCommit %}<span class="text-muted small">{{ ta.git_last_commit|default('laatste commit:') }} {{ gitStatus.lastCommit }}</span>{% endif %}
</div>
{% endif %}
{# Upload form (collapsed by default) #}
<div class="collapse mb-3" id="contentUploadForm">
<div class="card shadow-sm">
@@ -198,25 +54,29 @@
</div>
</div>
<div class="plugin-editor-layout">
{# File tree sidebar #}
<div class="plugin-file-tree">
<div class="card shadow-sm h-100">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.content_files|default('Content bestanden') }}</span>
</div>
<div class="card-body p-2" id="pluginFileTree">
{% if files is empty %}
<div class="small text-muted p-2">{{ ta.content_no_files|default('Geen bestanden gevonden.') }}</div>
{% else %}
{{ tree_macros.tree(files, relFile, editableExts, csrf_token) }}
{% endif %}
</div>
</div>
</div>
<div class="editor-layout">
{# File tree sidebar (shared partial) #}
{% include 'pages/_file-tree.twig' with {
'files': files,
'relFile': relFile,
'editableExts': editableExts,
'csrf_token': csrf_token,
'treeIdPrefix': 'content-tree',
'treeHeader': ta.content_files|default('Content bestanden'),
'treeEmptyText': ta.content_no_files|default('Geen bestanden gevonden.'),
'treeRouteBase': '/admin/content',
'treeRouteParams': '',
'treeDeleteRoute': '/admin/content-file-delete',
'treeDeleteName': '',
'treeDeleteValue': '',
'treeMoveRoute': '/admin/content-file-move',
'treeMoveParams': '',
'treeDirActions': true,
'treeHideActionsFor': [],
} %}
{# Editor main panel #}
<div class="plugin-editor-main">
<div class="editor-main">
{% if relFile %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
@@ -243,13 +103,16 @@
{# Metadata row: filename display, layout selector, plugins #}
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
<p class="form-control-plaintext form-control-sm mb-0"><code>{{ fileName }}</code></p>
<label for="new_filename" class="form-label text-muted small mb-1">{{ ta.filename|default('Bestandsnaam') }}</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="new_filename" name="new_filename" value="{{ fileBaseName }}" required>
<span class="input-group-text">.{{ fileExt }}</span>
</div>
</div>
{% if fileExt == 'md' %}
<div class="col-md-4">
<label for="layout" class="form-label">{{ ta.template_layout|default('Sjabloon / Layout') }} <small class="text-muted">({{ activeThemeName|default('default') }})</small></label>
<select class="form-select form-select-sm" id="layout" name="layout">
<option value="">{{ ta.theme_default|default('Thema standaard') }}</option>
{% for key, layoutFile in themeLayouts %}
<option value="{{ key }}" {{ currentLayout == key ? 'selected' : '' }}>{{ key|replace({'_': ' '})|capitalize }}{% if key == themeDefaultLayout %} (standaard){% endif %}</option>
{% endfor %}
@@ -257,33 +120,29 @@
</div>
{% if availablePlugins is not empty %}
<div class="col-md-4">
<label class="form-label d-block">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
<div class="d-flex flex-wrap gap-1">
<label class="form-label">{{ ta.visible_plugins|default('Zichtbare plugins') }}</label>
<div class="plugin-checkbox-group">
{% for plugin in availablePlugins %}
<input type="checkbox" class="btn-check" id="plugin-{{ plugin }}" name="plugins[]" value="{{ plugin }}" autocomplete="off" {{ plugin in selectedPlugins ? 'checked' : '' }}>
<label class="btn btn-outline-primary btn-sm" for="plugin-{{ plugin }}">{{ plugin }}</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="plugins[]" value="{{ plugin.name }}" id="plugin_{{ plugin.name }}" {{ plugin.name in selectedPlugins ? 'checked' : '' }}>
<label class="form-check-label" for="plugin_{{ plugin.name }}">{{ plugin.title }}</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</div>
{% if currentAuthorName or currentAuthorEmail or currentCreated %}
{# Created / edited timestamps (read-only, auto-filled) #}
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-person"></i> {{ ta.author_name|default('Auteur naam') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorName }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-envelope"></i> {{ ta.author_email|default('Auteur e-mail') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentAuthorEmail }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted"><i class="bi bi-calendar"></i> {{ ta.created|default('Aangemaakt') }}</label>
<label class="form-label text-muted small"><i class="bi bi-calendar-plus"></i> {{ ta.created|default('Aangemaakt') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentCreated }}" readonly>
</div>
<div class="col-md-4">
<label class="form-label text-muted small"><i class="bi bi-calendar-check"></i> {{ ta.edited|default('Bewerkt') }}</label>
<input type="text" class="form-control-plaintext form-control-sm" value="{{ currentEdited }}" readonly>
</div>
</div>
{% endif %}
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-wrapper">
<textarea name="content" id="editor-textarea" data-ext="{{ fileExt }}">{{ fileContent }}</textarea>
@@ -294,10 +153,9 @@
<button type="submit" class="btn btn-primary" title="{{ ta.save_ctrl_s|default('Opslaan (Ctrl+S)') }}">
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileName|replace({'.md': '', '.php': '', '.html': ''}) }}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
<a href="/{{ currentLang }}{% if fileDir %}/{{ fileDir }}{% endif %}/{{ fileBaseName }}{% if fileExt != 'md' %}.{{ fileExt }}{% endif %}" target="_blank" class="btn btn-outline-info" title="{{ ta.open_new_tab|default('Open in nieuw tabblad') }}">
<i class="bi bi-eye"></i> {{ ta.preview|default('Preview') }}
</a>
<a href="/admin/content-list?dir={{ fileDir|url_encode }}" class="btn btn-outline-secondary">{{ ta.list_view|default('Lijst weergave') }}</a>
</div>
</form>
{% else %}
@@ -385,10 +243,39 @@
document.addEventListener('DOMContentLoaded', function () {
var newFileModal = document.getElementById('newFileModal');
var newDirModal = document.getElementById('newDirModal');
var newFileBtnTop = document.getElementById('newFileBtnTop');
var newDirBtnTop = document.getElementById('newDirBtnTop');
// Track the selected directory for the top toolbar buttons
var selectedDir = '{{ selectedDir|default('') }}';
function updateTopButtons(dir) {
selectedDir = dir || '';
if (newFileBtnTop) newFileBtnTop.setAttribute('data-in-dir', selectedDir);
if (newDirBtnTop) newDirBtnTop.setAttribute('data-in-dir', selectedDir);
}
// Make folder rows selectable: clicking the folder name selects it
var tree = document.getElementById('editorFileTree');
if (tree) {
tree.addEventListener('click', function (e) {
var toggle = e.target.closest('.tree-toggle');
if (!toggle) return;
// Find the in-dir from the adjacent new-file button
var row = toggle.closest('.tree-dir-row');
if (!row) return;
var newFileBtn = row.querySelector('.tree-newfile-btn');
var dir = newFileBtn ? newFileBtn.getAttribute('data-in-dir') : '';
updateTopButtons(dir);
// Visually mark the selected folder
tree.querySelectorAll('.tree-toggle.is-selected').forEach(function (el) { el.classList.remove('is-selected'); });
toggle.classList.add('is-selected');
});
}
if (newFileModal) {
newFileModal.addEventListener('show.bs.modal', function (event) {
var trigger = event.relatedTarget;
var inDir = trigger ? trigger.getAttribute('data-in-dir') : '';
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
var hidden = document.getElementById('newFileDir');
if (hidden) hidden.value = inDir || '';
// Show the in-dir prefix as a hint in the placeholder
@@ -402,12 +289,21 @@ document.addEventListener('DOMContentLoaded', function () {
if (newDirModal) {
newDirModal.addEventListener('show.bs.modal', function (event) {
var trigger = event.relatedTarget;
var inDir = trigger ? trigger.getAttribute('data-in-dir') : '';
var inDir = trigger ? trigger.getAttribute('data-in-dir') : selectedDir;
var hidden = document.getElementById('newDirInDir');
if (hidden) hidden.value = inDir;
});
}
// Mark the initially selected folder (based on the opened file's directory)
if (selectedDir && tree) {
var initialBtn = tree.querySelector('.tree-newfile-btn[data-in-dir="' + selectedDir + '"]');
if (initialBtn) {
var initialToggle = initialBtn.closest('.tree-dir-row').querySelector('.tree-toggle');
if (initialToggle) initialToggle.classList.add('is-selected');
}
}
// Layout select → update frontmatter (spiegel van content-edit.twig)
var layoutSelect = document.getElementById('layout');
if (layoutSelect) {
@@ -4,7 +4,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen') }}</h2>
<h2 class="mb-0"><i class="bi bi-arrows-move"></i> {{ ta.file|default('Bestand') }} {{ ta.move_suffix|default('verplaatsen / hernoemen') }}</h2>
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> {{ ta.back|default('Terug') }}
</a>
@@ -15,22 +15,29 @@
<input type="hidden" name="file" value="{{ relFile }}">
<div class="card-body">
<div class="alert alert-info">
{{ ta.move_prefix|default('Verplaats') }} <strong>{{ itemName }}</strong>
{{ ta.move_prefix|default('Verplaats / hernoem') }} <strong>{{ itemName }}{{ itemExt }}</strong>
{% if itemDir %}<span class="text-muted">(content/{{ itemDir }})</span>{% else %}<span class="text-muted">(content/)</span>{% endif %}
{{ ta.move_to|default('naar:') }}
</div>
<div class="mb-3">
<label for="new_name" class="form-label">{{ ta.filename|default('Bestandsnaam') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="new_name" name="new_name" value="{{ itemName }}" required>
<span class="input-group-text">{{ itemExt }}</span>
</div>
<small class="form-text text-muted">{{ ta.name_help|default('Alleen letters, cijfers, punten, underscores en streepjes.') }}</small>
</div>
<div class="mb-3">
<label for="destination" class="form-label">{{ ta.target_folder|default('Doelmap') }} <small class="text-muted">(content/)</small></label>
<select class="form-select" id="destination" name="destination" required>
{% for directory in directories %}
<option value="{{ directory }}">{{ directory == '' ? '(content root)' : directory }}</option>
<option value="{{ directory }}" {{ directory == itemDir ? 'selected' : '' }}>{{ directory == '' ? '(content root)' : directory }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="card-footer bg-white">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.move|default('Verplaatsen') }}
<i class="bi bi-check-lg"></i> {{ ta.save|default('Opslaan') }}
</button>
<a href="/admin/content?file={{ relFile|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
@@ -34,7 +34,7 @@
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ ta.create|default('Aanmaken') }}
</button>
<a href="/admin/content-list?dir={{ dir|url_encode }}" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
<a href="/admin/content" class="btn btn-outline-secondary">{{ ta.cancel|default('Annuleren') }}</a>
</div>
</form>
</div>
+22 -107
View File
@@ -2,99 +2,10 @@
{% block title %}{{ ta.plugin_edit|default('Plugin bewerken: ') }}{{ pluginName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{# Recursively render the plugin file tree as a nested, collapsible list.
A folder is expanded by default when the currently selected file lives
somewhere inside it, so the user always sees where their file is. #}
{% macro tree(nodes, pluginName, relFile, editableExts, csrfToken) %}
{% import _self as macros %}
<ul class="plugin-tree">
{% for n in nodes %}
{% set isActive = n.path == relFile %}
{# A folder contains the active file when relFile starts with n.path + '/' #}
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
{# Icon per type #}
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'json' ? 'bi-filetype-json text-secondary' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension in ['css','scss'] ? 'bi-filetype-css text-info' : (n.extension == 'js' ? 'bi-filetype-js text-warning' : (n.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted')))))) %}
<li>
{% if n.is_dir %}
{% set collapseId = 'plugin-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
data-bs-toggle="collapse"
data-bs-target="#{{ collapseId }}"
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
title="{{ n.path }}">
<i class="bi bi-chevron-down tree-chevron"></i>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
</button>
<div id="{{ collapseId }}"
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
{% if n.children is not empty %}
{{ macros.tree(n.children, pluginName, relFile, editableExts, csrfToken) }}
{% endif %}
</div>
{% else %}
<a href="/admin/plugins-edit?plugin={{ pluginName|url_encode }}&file={{ n.path|url_encode }}"
class="tree-link {{ isActive ? 'is-active' : '' }}"
title="{{ n.path }}">
<span class="tree-chevron-spacer"></span>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
{% if n.extension in editableExts %}
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
{% endif %}
</a>
<a href="/admin/plugins-file-move?plugin={{ pluginName|url_encode }}&file={{ n.path|url_encode }}"
class="tree-move-btn" title="{{ ta.move|default('Verplaatsen') }}" aria-label="{{ ta.move|default('Verplaatsen') }}">
<i class="bi bi-arrows-move"></i>
</a>
<form method="POST" action="/admin/plugins-file-delete" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
<input type="hidden" name="plugin" value="{{ pluginName }}">
<input type="hidden" name="file" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm text-danger p-0 ms-1 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% import _self as tree_macros %}
{% block extra_css %}
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/admin/assets/css/editor.css">
<style>
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
.plugin-file-tree { width: 280px; flex: 0 0 280px; }
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
.plugin-tree li { padding: 0; position: relative; }
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Move and delete buttons: always visible, right-aligned, normal icon size */
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
@media (max-width: 768px) {
.plugin-editor-layout { flex-direction: column; }
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
.plugin-file-tree .card-body { max-height: 260px; }
}
</style>
{% include 'pages/_editor-styles.twig' %}
{% endblock %}
{% block content %}
@@ -134,25 +45,29 @@
</div>
</div>
<div class="plugin-editor-layout">
{# File tree sidebar #}
<div class="plugin-file-tree">
<div class="card shadow-sm h-100">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.plugin_files|default('Plugin bestanden') }}</span>
</div>
<div class="card-body p-2" id="pluginFileTree">
{% if files is empty %}
<div class="small text-muted p-2">{{ ta.plugin_no_files|default('Geen bestanden gevonden.') }}</div>
{% else %}
{{ tree_macros.tree(files, pluginName, relFile, editableExts, csrf_token) }}
{% endif %}
</div>
</div>
</div>
<div class="editor-layout">
{# File tree sidebar (shared partial) #}
{% include 'pages/_file-tree.twig' with {
'files': files,
'relFile': relFile,
'editableExts': editableExts,
'csrf_token': csrf_token,
'treeIdPrefix': 'plugin-tree',
'treeHeader': ta.plugin_files|default('Plugin bestanden'),
'treeEmptyText': ta.plugin_no_files|default('Geen bestanden gevonden.'),
'treeRouteBase': '/admin/plugins-edit',
'treeRouteParams': 'plugin=' ~ pluginName|url_encode ~ '&',
'treeDeleteRoute': '/admin/plugins-file-delete',
'treeDeleteName': 'plugin',
'treeDeleteValue': pluginName,
'treeMoveRoute': '/admin/plugins-file-move',
'treeMoveParams': 'plugin=' ~ pluginName|url_encode ~ '&',
'treeDirActions': false,
'treeHideActionsFor': [],
} %}
{# Editor main panel #}
<div class="plugin-editor-main">
<div class="editor-main">
{% if relFile %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
+22 -104
View File
@@ -2,98 +2,12 @@
{% block title %}{{ ta.theme_edit|default('Thema bewerken: ') }}{{ themeName }} - {{ ta.admin_title|default('CodePress Admin') }}{% endblock %}
{# Recursively render the theme file tree as a nested, collapsible list.
A folder is expanded by default when the currently selected file lives
somewhere inside it, so the user always sees where their file is. #}
{% macro tree(nodes, themeName, relFile, editableExts, csrfToken) %}
{% import _self as macros %}
<ul class="plugin-tree">
{% for n in nodes %}
{% set isActive = n.path == relFile %}
{% set containsActive = n.is_dir and relFile starts with (n.path ~ '/') %}
{% set icon = n.is_dir ? 'bi-folder-fill text-warning' : (n.extension == 'twig' ? 'bi-filetype-tfn text-info' : (n.extension == 'php' ? 'bi-file-code text-success' : (n.extension == 'json' ? 'bi-filetype-json text-secondary' : (n.extension == 'md' ? 'bi-file-text text-primary' : (n.extension in ['css','scss'] ? 'bi-filetype-css text-info' : (n.extension == 'js' ? 'bi-filetype-js text-warning' : (n.extension == 'html' ? 'bi-file-earmark text-info' : 'bi-file text-muted'))))))) %}
<li>
{% if n.is_dir %}
{% set collapseId = 'theme-tree-' ~ n.path|replace({'/':'-','.':'-'}) %}
<button type="button" class="tree-link tree-toggle btn-toggle {{ containsActive ? '' : 'collapsed' }}"
data-bs-toggle="collapse"
data-bs-target="#{{ collapseId }}"
aria-expanded="{{ containsActive ? 'true' : 'false' }}"
title="{{ n.path }}">
<i class="bi bi-chevron-down tree-chevron"></i>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
</button>
<div id="{{ collapseId }}"
class="tree-collapse collapse {{ containsActive ? 'show' : '' }}">
{% if n.children is not empty %}
{{ macros.tree(n.children, themeName, relFile, editableExts, csrfToken) }}
{% endif %}
</div>
{% else %}
<a href="/admin/theme-edit?theme={{ themeName|url_encode }}&file={{ n.path|url_encode }}"
class="tree-link {{ isActive ? 'is-active' : '' }}"
title="{{ n.path }}">
<span class="tree-chevron-spacer"></span>
<i class="bi {{ icon }}"></i>
<span class="text-truncate">{{ n.name }}</span>
{% if n.extension in editableExts %}
<span class="badge bg-secondary badge-ext">{{ n.extension|upper }}</span>
{% endif %}
</a>
{% if n.path != 'theme.json' %}
<a href="/admin/theme-file-move?theme={{ themeName|url_encode }}&file={{ n.path|url_encode }}"
class="tree-move-btn" title="{{ ta.move|default('Verplaatsen') }}" aria-label="{{ ta.move|default('Verplaatsen') }}">
<i class="bi bi-arrows-move"></i>
</a>
<form method="POST" action="/admin/theme-file-delete" class="tree-delete-form" onsubmit="return confirm('{{ ta.confirm_delete_file|default('Weet je zeker dat je dit bestand wilt verwijderen?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
<input type="hidden" name="theme" value="{{ themeName }}">
<input type="hidden" name="file" value="{{ n.path }}">
<button type="submit" class="btn btn-link btn-sm text-danger p-0 ms-1 tree-delete-btn" title="{{ ta.delete|default('Verwijderen') }}" aria-label="{{ ta.delete|default('Verwijderen') }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{% import _self as tree_macros %}
{% block extra_css %}
<link rel="stylesheet" href="/admin/assets/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/admin/assets/css/editor.css">
{% include 'pages/_editor-styles.twig' %}
<style>
.plugin-editor-layout { display: flex; gap: 1rem; align-items: stretch; }
.plugin-file-tree { width: 280px; flex: 0 0 280px; }
.plugin-editor-main { flex: 1 1 auto; min-width: 0; }
.plugin-tree { list-style: none; padding-left: 0; margin: 0; }
.plugin-tree ul { list-style: none; padding-left: 1.1rem; margin: 0; }
.plugin-tree li { padding: 0; position: relative; }
.plugin-tree li > .tree-link { display: flex; align-items: center; gap: .3rem; padding: .2rem .4rem; border-radius: .25rem; text-decoration: none; color: inherit; font-size: .9rem; line-height: 1.4; }
.plugin-tree li > .tree-link:hover { background-color: rgba(13,110,253,.08); }
.plugin-tree li > .tree-link.is-active { background-color: var(--bs-primary-bg-subtle, #cfe2ff); font-weight: 600; }
.plugin-tree .tree-toggle { cursor: pointer; user-select: none; width: 100%; text-align: left; background: transparent; border: 0; color: inherit; }
.plugin-tree .tree-chevron { transition: transform .15s ease; flex: 0 0 auto; }
.plugin-tree .tree-chevron-spacer { display: inline-block; width: .9rem; flex: 0 0 auto; }
.plugin-tree .btn-toggle.collapsed .tree-chevron { transform: rotate(-90deg); }
.plugin-tree .badge-ext { font-size: .6rem; margin-left: auto; }
.plugin-tree .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plugin-tree .tree-delete-form { position: absolute; right: .25rem; top: 50%; transform: translateY(-50%); margin: 0; }
.plugin-tree .tree-delete-btn { font-size: .9rem; line-height: 1; padding: .15rem; color: #dc3545; background: transparent; border: 0; }
.plugin-tree .tree-delete-btn:hover { color: #b02a37; }
.plugin-tree .tree-move-btn { position: absolute; right: 1.75rem; top: 50%; transform: translateY(-50%); font-size: .9rem; line-height: 1; padding: .15rem; color: #6c757d; text-decoration: none; }
.plugin-tree .tree-move-btn:hover { color: #0d6efd; }
.plugin-tree li > .tree-link { padding-right: 3.5rem; }
.plugin-file-tree .card-body { max-height: 70vh; overflow-y: auto; }
.theme-scss-status { font-size: .75rem; }
@media (max-width: 768px) {
.plugin-editor-layout { flex-direction: column; }
.plugin-file-tree { width: 100%; flex-basis: auto; max-height: 320px; }
.plugin-file-tree .card-body { max-height: 260px; }
}
</style>
{% endblock %}
@@ -144,25 +58,29 @@
</div>
</div>
<div class="plugin-editor-layout">
{# File tree sidebar #}
<div class="plugin-file-tree">
<div class="card shadow-sm h-100">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="small text-muted fw-semibold"><i class="bi bi-folder2-open"></i> {{ ta.theme_files|default('Thema bestanden') }}</span>
</div>
<div class="card-body p-2" id="pluginFileTree">
{% if files is empty %}
<div class="small text-muted p-2">{{ ta.theme_no_files|default('Geen bestanden gevonden.') }}</div>
{% else %}
{{ tree_macros.tree(files, themeName, relFile, editableExts, csrf_token) }}
{% endif %}
</div>
</div>
</div>
<div class="editor-layout">
{# File tree sidebar (shared partial) #}
{% include 'pages/_file-tree.twig' with {
'files': files,
'relFile': relFile,
'editableExts': editableExts,
'csrf_token': csrf_token,
'treeIdPrefix': 'theme-tree',
'treeHeader': ta.theme_files|default('Thema bestanden'),
'treeEmptyText': ta.theme_no_files|default('Geen bestanden gevonden.'),
'treeRouteBase': '/admin/theme-edit',
'treeRouteParams': 'theme=' ~ themeName|url_encode ~ '&',
'treeDeleteRoute': '/admin/theme-file-delete',
'treeDeleteName': 'theme',
'treeDeleteValue': themeName,
'treeMoveRoute': '/admin/theme-file-move',
'treeMoveParams': 'theme=' ~ themeName|url_encode ~ '&',
'treeDirActions': false,
'treeHideActionsFor': ['theme.json'],
} %}
{# Editor main panel #}
<div class="plugin-editor-main">
<div class="editor-main">
{% if relFile %}
<nav aria-label="breadcrumb" class="mb-2">
<ol class="breadcrumb mb-0">
+114 -61
View File
@@ -90,8 +90,9 @@ class CodePressCMS {
* Removes any characters that are not alphanumeric, dashes, underscores, or slashes
*/
private function sanitizePageParam(string $page): string {
// Remove any characters that could be used for XSS
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $page);
// Remove any characters that could be used for XSS; keep the dot so
// content file extensions (md/php/html) survive in the URL.
$sanitized = preg_replace('/[^a-zA-Z0-9_\-\/.]/', '', $page);
return $sanitized ?: 'invalid-page';
}
@@ -330,8 +331,8 @@ class CodePressCMS {
$result = [];
foreach ($items as $item) {
// Skip hidden/system entries: dotfiles, -assets, _drafts, etc.
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
// Skip assets directory (old name, kept for safety)
if ($item === 'assets' && is_dir($dir . '/' . $item)) continue;
@@ -359,12 +360,13 @@ class CodePressCMS {
// Always use filename for navigation (not H1 titles from content)
$filename = pathinfo($item, PATHINFO_FILENAME);
$title = $this->formatDisplayName($filename);
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
// Keep the extension in the URL so files sharing the same name
// but different type each keep their own viewable link.
$result[] = [
'type' => 'file',
'title' => $title,
'path' => $pathWithoutExt,
'url' => $this->buildUrl($pathWithoutExt)
'path' => $relativePath,
'url' => $this->buildUrl($relativePath)
];
}
}
@@ -398,8 +400,8 @@ class CodePressCMS {
$items = scandir($dir);
foreach ($items as $item) {
// Skip hidden/system entries (consistent met scanDirectory)
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
@@ -481,8 +483,13 @@ class CodePressCMS {
$page = $_GET['page'] ?? $this->getEffectiveDefaultPage();
// Limit length
$page = substr($page, 0, 255);
// Only remove file extension at the end, not all dots
$pageWithoutExt = preg_replace('/\.(md|php|html)$/', '', $page);
// Detect explicit extension from the URL (e.g. /nl/test.php → use test.php)
$explicitExt = null;
if (preg_match('/\.(md|php|html)$/i', $page, $extMatch)) {
$explicitExt = strtolower($extMatch[1]);
}
// Strip any content extension from the URL for the base path
$pageWithoutExt = preg_replace('/\.(md|php|html)$/i', '', $page);
$filePath = $this->config['content_dir'] . '/' . $pageWithoutExt;
@@ -500,43 +507,32 @@ class CodePressCMS {
$actualFilePath = null;
// Check for exact file matches if no directory found
if (file_exists($filePath . '.md')) {
$actualFilePath = $filePath . '.md';
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
} elseif (file_exists($filePath . '.php')) {
$actualFilePath = $filePath . '.php';
$result = $this->parsePHP($actualFilePath);
} elseif (file_exists($filePath . '.html')) {
$actualFilePath = $filePath . '.html';
$result = $this->parseHTML(file_get_contents($actualFilePath));
} elseif (file_exists($filePath)) {
$actualFilePath = $filePath;
$extension = pathinfo($filePath, PATHINFO_EXTENSION);
if ($extension === 'md') {
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
} elseif ($extension === 'php') {
$result = $this->parsePHP($actualFilePath);
} elseif ($extension === 'html') {
$result = $this->parseHTML(file_get_contents($actualFilePath));
// If the URL has an explicit extension, try that exact file first
if ($explicitExt !== null && file_exists($filePath . '.' . $explicitExt)) {
$actualFilePath = $filePath . '.' . $explicitExt;
$result = $this->resolveContentByType($filePath, $explicitExt);
if ($result !== null) {
$actualFilePath = $result['path'];
$result = $result['content'];
}
}
// No explicit extension: serve the first matching file (md > php > html)
if (!isset($result)) {
$resolved = $this->resolveContentByType($filePath);
if ($resolved !== null) {
$actualFilePath = $resolved['path'];
$result = $resolved['content'];
}
}
// If no exact match found, check for language-specific versions
if (!isset($result)) {
$result = null; // Reset result before language-specific search
$langPrefix = $this->currentLanguage;
if (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md')) {
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.md';
$result = $this->parseMarkdown(file_get_contents($actualFilePath), $actualFilePath);
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php')) {
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.php';
$result = $this->parsePHP($actualFilePath);
} elseif (file_exists($this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html')) {
$actualFilePath = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt . '.html';
$result = $this->parseHTML(file_get_contents($actualFilePath));
$langBase = $this->config['content_dir'] . '/' . $langPrefix . '.' . $pageWithoutExt;
$resolved = $this->resolveContentByType($langBase, $explicitExt);
if ($resolved !== null) {
$actualFilePath = $resolved['path'];
$result = $resolved['content'];
}
}
@@ -553,6 +549,40 @@ class CodePressCMS {
return $this->getError404();
}
/**
* Resolve the first content file matching a base path by type priority.
*
* A request without explicit extension (e.g. "test") serves the first
* existing file among test.md → test.php → test.html. When $preferredExt
* is given (the URL carried an extension), that type is tried first so
* /nl/test.php keeps serving test.php even when test.md also exists.
*
* @param string $basePath Absolute path without extension
* @param string $preferredExt Optional extension to try first (md|php|html)
* @return array|null ['path' => string, 'content' => array] or null
*/
private function resolveContentByType(string $basePath, ?string $preferredExt = null): ?array
{
$order = ['md', 'php', 'html'];
if ($preferredExt !== null && in_array($preferredExt, $order, true)) {
$order = array_unique(array_merge([$preferredExt], $order));
}
foreach ($order as $ext) {
$candidate = $basePath . '.' . $ext;
if (file_exists($candidate)) {
if ($ext === 'md') {
$content = $this->parseMarkdown(file_get_contents($candidate), $candidate);
} elseif ($ext === 'php') {
$content = $this->parsePHP($candidate);
} else {
$content = $this->parseHTML(file_get_contents($candidate));
}
return ['path' => $candidate, 'content' => $content];
}
}
return null;
}
/**
* Get file information including creation and modification dates
*
@@ -836,7 +866,11 @@ class CodePressCMS {
private function autoLinkPageTitles($content, $excludeTitle = '') {
$pages = $this->getAllPageTitles();
foreach ($pages as $pagePath => $pageTitle) {
foreach ($pages as $page) {
$pageTitle = $page['title'];
$pagePath = $page['path'];
// Alleen bestanden auto-linken, geen mappen
if ($page['type'] === 'folder') continue;
if (strtolower($pageTitle) === strtolower($excludeTitle)) {
continue;
}
@@ -863,9 +897,10 @@ class CodePressCMS {
}
/**
* Get all page titles from content directory
* Get all content entries (mappen + bestanden) from the content directory.
*
* @return array Associative array of page paths to titles
* @return array List of entries: ['path' => ..., 'title' => ..., 'type' => ...]
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
*/
public function getAllPageTitles() {
$pages = [];
@@ -874,7 +909,13 @@ class CodePressCMS {
}
/**
* Recursively scan for page titles in directory
* Recursively scan for content entries in a directory.
*
* Returns a flat list of ['path' => relativePath, 'title' => ..., 'type' => ...].
* Mappen krijgen type 'folder'; bestanden krijgen hun extensie als type.
* Bestanden met dezelfde naam maar ander type blijven apart (de extensie
* zit in het pad), en mappen krijgen hun eigen entry zodat de boom zichtbaar
* blijft.
*
* @param string $dir Directory to scan
* @param string $prefix Relative path prefix
@@ -888,26 +929,30 @@ class CodePressCMS {
sort($items);
foreach ($items as $item) {
// Skip hidden/system entries (consistent met scanDirectory)
if ($item[0] === '.' || $item[0] === '-' || $item[0] === '_') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix ? $prefix . '/' . $item : $item;
if (is_dir($path)) {
$pages[] = [
'path' => $relativePath,
'title' => $this->formatDisplayName($item),
'type' => 'folder',
];
$this->scanForPageTitles($path, $relativePath, $pages);
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
$title = $this->extractPageTitle($path);
if ($title && !empty(trim($title))) {
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $title;
} else {
// Fallback to clean filename if no title found in content
if (!$title || empty(trim($title))) {
$filename = basename($path, pathinfo($path, PATHINFO_EXTENSION));
$cleanName = $this->formatDisplayName($filename);
$pagePath = preg_replace('/\.[^.]+$/', '', $relativePath);
$pages[$pagePath] = $cleanName;
$title = $this->formatDisplayName($filename);
}
$pages[] = [
'path' => $relativePath,
'title' => $title,
'type' => pathinfo($item, PATHINFO_EXTENSION),
];
}
}
}
@@ -1002,9 +1047,15 @@ class CodePressCMS {
// Make API and metadata available to the included file
$api = new ContentAPI($this);
$pageMetadata = $metadata;
include $filePath;
$returnedContent = include $filePath;
$content = ob_get_clean();
// If the PHP file returned a string, use that as content (callback style).
// Otherwise use the buffered echo output.
if (is_string($returnedContent) && $returnedContent !== '') {
$content = $returnedContent;
}
// Remove any remaining metadata from PHP output
$content = preg_replace('/^---\s*\n.*?\n---\s*\n/s', '', $content);
@@ -1232,7 +1283,8 @@ class CodePressCMS {
$allItems = [];
foreach ($items as $item) {
if ($item[0] === '.') continue;
// Skip hidden/system entries: dotfiles (.map, .bak) en dash-prefixed (-assets)
if ($item[0] === '.' || $item[0] === '-') continue;
$itemPath = $dirPath . '/' . $item;
$relativePath = $pagePath ? $pagePath . '/' . $item : $item;
@@ -1246,15 +1298,16 @@ class CodePressCMS {
'type' => 'directory'
];
} elseif (preg_match('/\.(md|php|html)$/', $item)) {
// Each file keeps its own link (incl. extension) so every type
// stays viewable at the frontend even when names overlap.
$extractedTitle = $this->extractPageTitle($itemPath);
$fileTitle = $extractedTitle ?: ucfirst(pathinfo($item, PATHINFO_FILENAME));
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $relativePath);
$icon = pathinfo($item, PATHINFO_EXTENSION) === 'md' ? 'bi-file-text' :
(pathinfo($item, PATHINFO_EXTENSION) === 'php' ? 'bi-file-code' : 'bi-file-earmark');
$allItems[] = [
'name' => $fileTitle,
'path' => $pathWithoutExt,
'url' => $this->buildUrl($pathWithoutExt),
'path' => $relativePath,
'url' => $this->buildUrl($relativePath),
'icon' => $icon,
'type' => 'file'
];
+23 -9
View File
@@ -17,9 +17,10 @@ class ContentAPI
}
/**
* Get all pages as a flat array of path => title pairs
* Get all content entries (mappen + bestanden) as a list of entry arrays.
*
* @return array Associative array like ['index' => 'Home', 'over-ons' => 'Over ons']
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
* type is 'md'/'php'/'html' voor bestanden, 'folder' voor mappen.
*/
public function getAllPages(): array
{
@@ -29,18 +30,32 @@ class ContentAPI
/**
* Get a single page by path, including title, content, layout, and metadata
*
* @param string $path Page path without extension (e.g. 'over-ons' or 'blog/post-1')
* De $path mag een extensie bevten (bijv. 'over-ons.php') of niet ('over-ons').
* Zonder extensie wordt het eerste bestaande bestand gekozen (md > php > html);
* met extensie wordt dat specifieke bestand gekozen.
*
* @param string $path Page path, al dan niet met extensie
* @return array|null Page data or null if not found
*/
public function getPage(string $path): ?array
{
$contentDir = $this->cms->config['content_dir'];
$path = preg_replace('/\.(md|php|html)$/', '', $path);
// Detecteer expliciete extensie en strip hem voor de basis
$preferredExt = null;
if (preg_match('/\.(md|php|html)$/i', $path, $m)) {
$preferredExt = strtolower($m[1]);
}
$path = preg_replace('/\.(md|php|html)$/i', '', $path);
$filePath = $contentDir . '/' . $path;
$extensions = ['md', 'php', 'html'];
// Probeer de gevraagde extensie eerst, dan de standaard volgorde
$order = ['md', 'php', 'html'];
if ($preferredExt !== null) {
$order = array_unique(array_merge([$preferredExt], $order));
}
$actualPath = null;
foreach ($extensions as $ext) {
foreach ($order as $ext) {
if (file_exists($filePath . '.' . $ext)) {
$actualPath = $filePath . '.' . $ext;
break;
@@ -141,14 +156,13 @@ class ContentAPI
/**
* Check if a page exists at the given path
*
* @param string $path Page path without extension
* @param string $path Page path, al dan niet met extensie
* @return bool
*/
public function pageExists(string $path): bool
{
$contentDir = $this->cms->config['content_dir'];
$path = preg_replace('/\.(md|php|html)$/', '', $path);
$basePath = $contentDir . '/' . $path;
$basePath = $contentDir . '/' . preg_replace('/\.(md|php|html)$/i', '', $path);
return file_exists($basePath . '.md')
|| file_exists($basePath . '.php')
+3 -1
View File
@@ -224,7 +224,9 @@ class CMSAPI implements PluginAPIInterface
}
/**
* Get all pages with their metadata
* Get all content entries (mappen + bestanden) as a list of entry arrays.
*
* @return array List of ['path' => ..., 'title' => ..., 'type' => ...]
*/
public function getAllPages(): array
{
+160
View File
@@ -0,0 +1,160 @@
# CodePress CMS v2.6.3 — Release Notes
**Release datum:** 2026-08-20
**Codename:** Lyra
**Status:** stable
---
## Samenvatting
Deze release richt zich op **content met meerdere bestandstypes** en een
**duidelijkere Content API**. Content bestanden die dezelfde naam delen maar een
ander type hebben (`test.md`, `test.php`, `test.html`) worden nu correct
afgehandeld: de frontend serveert per extensie het juiste bestand, en de admin
accepteert en toont ze allemaal. Daarnaast is de `getAllPages()` API herschreven
naar een rijkere array-structuur en zijn de verberg-prefixen voor mappen/bestanden
gewijzigd (`.` vervangt `_`).
---
## Nieuwe features & wijzigingen
### 1. Content bestanden met dezelfde naam, ander type
Eerder kon een content map niet twee bestanden bevatten met dezelfde naam maar
een verschillend type (bijv. `test.md` + `test.php`). De frontend routeerde
alleen op naam zonder extensie, waardoor slechts één bestand geserveerd werd.
Nu:
- **URL met extensie** (`/nl/test.php`) serveert exact dat bestand.
- **URL zonder extensie** (`/nl/test`) valt terug op het eerste bestaande bestand
met prioriteit **md > php > html** (via de nieuwe `resolveContentByType()`
helper).
- De admin content editor accepteert bestanden met dezelfde naam (ander type);
de "bestaat al" check is per extensie. De melding is verduidelijkt naar
"Bestand met dit type bestaat al."
- De admin preview-knop linkt per extensie (`test.php``/nl/test.php`) zodat
elk type individueel te bekijken blijft.
### 2. Frontend navigatie tonen alle bestandstypes
`scanDirectory()` (menu), `getDirectoryListing()` (directory view) en
`searchInDirectory()` (zoeken) tonen nu elk bestand apart met de extensie
behouden in de URL. Bestanden met dezelfde naam maar ander type zijn niet meer
samengevoegd tot één menu-item — elk type krijgt zijn eigen link.
### 3. getAllPages() array structuur
`ContentAPI::getAllPages()` (en `CMSAPI::getAllPages()` voor plugins) gaf eerder
een associative array `['pad' => 'titel']`. Nu is het een **list van entry
arrays**:
```php
[
['path' => 'test-map', 'title' => 'Test Map', 'type' => 'folder'],
['path' => 'test-map/test.md','title' => 'Nieuwe pagina', 'type' => 'md'],
['path' => 'test.md', 'title' => 'Test', 'type' => 'md'],
['path' => 'test.php', 'title' => 'Test.', 'type' => 'php'],
['path' => 'test.html', 'title' => 'Nieuwe pagina', 'type' => 'html'],
]
```
- `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
- Mappen krijgen hun eigen entry zodat de boom-structuur zichtbaar blijft.
- Bestanden met dezelfde naam maar ander type botsen niet meer (de extensie
zit in het pad).
`autoLinkPageTitles()` is aangepast aan de nieuwe structuur en slaat mappen over.
### 4. Verberg-prefix logica: `.` i.p.v. `_`
Vooraf verborg de frontend mappen/bestanden die begonnen met `.`, `-` of `_`.
Nu is `_` **geen** verberg-prefix meer — alleen `.` (en `-`) verbergen aan de
frontend. Dit maakt `.map` of `.bak` de standaard manier om content te verbergen.
De admin daarentegen toont **wél** alle `.` bestanden en mappen (`.bak`,
`.map`), behalve `.git`/`.gitkeep`. De `scanEditorFilesNode()` functie heeft een
nieuwe `$scope` parameter die bepaalt of dotfiles getoond worden (alleen voor
`content` scope).
`collectContentPages()` (admin config default-page dropdown) toont nu ook `.`
bestanden.
### 5. ContentAPI getPage()/pageExists() met extensie
`ContentAPI::getPage('test.php')` en `pageExists('test.php')` respecteren nu de
expliciete extensie — ze openen dat specifieke bestand in plaats van terug te
vallen op md-prioriteit.
### 6. Handleiding content-api.md herschreven
De handleiding `guide/nl/content-beheerder/content-api.md` en de Engelse
tegenhanger zijn volledig herschreven:
- **Hoe content naar Twig gaat**: echo/print vs return mechanisme duidelijk
uitgelegd met voorbeelden
- **Wat je niet kunt**: geen eigen Twig variabelen vanuit PHP; je voedt alleen
`{{ content }}`
- **Beschikbare variabelen** in een `.php` bestand: `$api` en `$pageMetadata`
- **Nieuwe getAllPages() structuur** met type-veld en voorbeelden
- **Layout kiezen**: hoe de `layout` frontmatter key koppelt naar `theme.json`
- **Veiligheidsrichtlijnen**
---
## Technische details
### Nieuwe/gewijzigde functies (core)
- `CodePressCMS::resolveContentByType(string $basePath, ?string $preferredExt = null): ?array`
— Nieuwe helper die het eerste bestaande bestand (md > php > html) serveert,
of het bestand met de gevraagde extensie als `$preferredExt` opgegeven is.
- `CodePressCMS::scanForPageTitles()` — herschreven naar list van
`['path','title','type']` entries; mappen als eigen entry; `_` niet meer
verborgen.
- `CodePressCMS::scanDirectory()` / `searchInDirectory()` / `getDirectoryListing()`
`_` verwijderd uit verberg-logica; extensie behouden in URLs.
- `CodePressCMS::autoLinkPageTitles()` — aangepast aan nieuwe array structuur.
- `ContentAPI::getPage()` / `pageExists()` — respecteren expliciete extensie.
- `ContentAPI::getAllPages()` / `CMSAPI::getAllPages()` — docblocks bijgewerkt.
- `scanEditorFilesNode()` — nieuwe `$scope` parameter; content-scope toont
dotfiles (behalve .git/.gitkeep).
- `scanContentDir()` / `collectContentPages()` — tonen nu `.` bestanden/mappen.
### Gewijzigde bestanden
- `cms/core/class/CodePressCMS.php` — resolveContentByType, scanForPageTitles,
scanDirectory, searchInDirectory, getDirectoryListing, autoLinkPageTitles,
sanitizePageParam (punt toegestaan), getPage (explicitExt terug)
- `cms/core/class/ContentAPI.php` — getPage/pageExists met extensie, getAllPages docblock
- `cms/core/plugin/CMSAPI.php` — getAllPages docblock
- `public/admin.php` — scanContentDir, scanEditorFilesNode, collectContentPages,
handleContentNew/handleContentFiles meldingen
- `admin/theme/default/views/pages/content-files.twig` — preview link per extensie
- `admin/theme/default/views/pages/content-edit.twig` — preview link per extensie
- `guide/nl/content-beheerder/content-api.md` — herschreven
- `guide/en/content-beheerder/content-api.md` — herschreven
- `version.php` — 2.6.3
- `README.md` / `README.en.md` — versie 2.6.3
- `TODO.md` — v2.6.3 sectie toegevoegd
---
## Tests
- PHP lint: alle bestanden syntactisch correct (`php -l`)
- Live getest via Apache (development.codepress.noorlander.info):
- `/nl/test.php` serveert test.php
- `/nl/test.html` serveert test.html
- `/nl/test` (zonder ext) serveert test.md (md prioriteit)
- Admin content boom toont `.bak`, `.map`, `_images` en alle bestandstypes
- Frontend navigatie verbergt `.bak`/`.map`, toont alle bestandstypes apart
- `$api->getAllPages()` in test.php toont pad/titel/type structuur
---
## Volgende stappen
Zie `TODO.md` voor openstaand werk (multidomein implementatie).
+36 -40
View File
@@ -1,13 +1,8 @@
# Content management
CodePress offers two views for content management:
CodePress uses a **tree view** (`/admin/content`) for content management: a file browser sidebar + CodeMirror editor, uniform with the plugin and theme editors. The old list/table view has been removed.
1. **Tree view** (`/admin/content`) — default, file browser sidebar + CodeMirror editor (uniform with plugin/theme editors)
2. **List view** (`/admin/content-list`) — classic table with filter, upload, new folder/file per folder
Both work side-by-side and use the same content directory. Switch between views via the "List view"/"Tree view" buttons at the top right. The tree view offers the same functionality as the list view, but with a nested file-tree sidebar like the plugin and theme editors — for a consistent editor experience.
## Tree view (content)
## Content editor
### File browser sidebar
- Shows the nested file tree of `content/`
@@ -19,6 +14,30 @@ Both work side-by-side and use the same content directory. Switch between views
### Editable file types
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent with the existing content-edit page.
All types support `---` frontmatter (layout, created, edited, plugins). The CMS `parseMetadata()` extracts frontmatter before processing. For PHP files, frontmatter is stripped from the output.
#### PHP content files
PHP files can deliver content in two ways:
1. **Echo** (output buffering): everything `echo`ed or outside `<?php` tags is used as content
2. **Return** (callback style): `return '<h1>Hello</h1>';` — the returned string is used as content
In PHP files, the `ContentAPI` is available via `$api`:
```php
<?php
$menu = $api->getMenu();
$pages = $api->getAllPages();
$config = $api->getConfig('site_title');
return '<h1>' . htmlspecialchars($config) . '</h1>';
```
#### Frontend URLs and file names
The CMS searches files in order: `.md``.php``.html`. If files share the same name (e.g. `test.md` and `test.php`), `/nl/test` always opens `test.md`. To open a specific file type, add the extension to the URL:
- `/nl/test` → opens `test.md` (or `.php` / `.html` if `.md` doesn't exist)
- `/nl/test.php` → opens `test.php` (explicit extension)
- `/nl/test.html` → opens `test.html` (explicit extension)
The preview link in the editor automatically uses the extension for non-`.md` files.
### Set image size (Markdown)
The markdown editor toolbar has an "Image size" button (expand icon):
1. Select an image in the editor in markdown format `![alt](url)`
@@ -41,7 +60,7 @@ Images in `content/` are served via the `/-media/` endpoint (content/ lives outs
- Enter a path within content (e.g. `en.page` or `blog/en.post`)
- Choose the file type (Markdown/PHP/HTML)
- Subfolders are created automatically
- Frontmatter with `layout`, `author_name`, `author_email`, `created` is auto-generated
- Frontmatter with `layout`, `created`, `edited` is auto-generated
### Upload a file
- Click **Upload** to upload files to `content/`
@@ -62,27 +81,22 @@ Images in `content/` are served via the `/-media/` endpoint (content/ lives outs
On the editor page you can choose the layout from the layouts defined in `theme.json` (template mapping). The selected layout is stored in the frontmatter `layout:` key.
### Plugins on pages
- Content plugins (from `plugin.json` with `type: "content"`) appear in the plugin selection
- Choose which plugins run on the page
- Only active **content plugins** (from `plugin.json` with `type: "content"`) appear in the plugin multiselect; system plugins (like Statistics/Logs/Dashboard) are excluded
- Choose which plugins run on the page via the multiselect (use Ctrl/Cmd+click for multiple)
- The plugin selection is stored in the frontmatter `plugins:` key
### Git / Backup integration (Phase 5)
At the top of the content editor there are backup and git actions:
- **Git init**: initializes a git repository in `content/` (if none exists yet)
- **Commit**: commits all uncommitted changes (only if there is a git repo and there are changes)
- **Backup**: link to the backup page (`/admin/content-backup`) for ZIP backup/restore
- The git status badge shows the current branch, whether there are uncommitted changes, and the last commit
### Backup integration
At the top of the content editor there is a **Backup** button linking to the backup page (`/admin/content-backup`) for ZIP backup/restore and (if available) git versioning. Git integration in the editor sidebar has been removed; git will become a system plugin later.
### Frontmatter
The editor updates the frontmatter live when layout or plugin selection changes:
The editor shows the **Created** and **Edited** timestamps (read-only, automatically updated on every save). The `edited:` value is refreshed on each save.
```markdown
---
layout: left_sidebar
author_name: Admin
author_email: admin@example.com
created: 2026-08-19 10:30:25
edited: 2026-08-20 14:22:01
plugins: HTMLBlock, Navigation
---
@@ -91,24 +105,6 @@ plugins: HTMLBlock, Navigation
Content...
```
## List view (content)
### Managing files
- **New folder** — Create folder structure
- **New file** — Create a page (`.md`, `.php`, `.html`)
- **Edit** — Modify existing content in the CodeMirror editor
- **Rename** — Change file or folder names
- **Move** — Move content to another folder
- **Delete** — Remove content
- **Rename folder** — Change a folder name
### Editor (content-edit)
- **CodeMirror** with syntax highlighting (Markdown, PHP, HTML)
- **Toolbar** for quickly inserting Markdown
- **Shortcuts**: Ctrl+S (save), Ctrl+N (new)
## Frontmatter
The editor updates the frontmatter live when layout or plugin selection changes:
@@ -116,9 +112,9 @@ The editor updates the frontmatter live when layout or plugin selection changes:
```markdown
---
layout: left_sidebar
plugins:
- HTMLBlock
- Navigation
created: 2026-08-19 10:30:25
edited: 2026-08-20 14:22:01
plugins: HTMLBlock, Navigation
---
# Page title
+135 -49
View File
@@ -1,84 +1,132 @@
# Content API
The Content API is available in PHP content files (`.php`) and provides a safe, read-only interface to CMS data.
PHP content files (`.php`) run inside the CMS and have access to a safe,
read-only API via the `$api` variable. The output of such a file is
automatically placed into the active Twig layout at the `{{ content }}` spot.
## Usage in PHP content files
## How to pass content to the Twig template
There are **two ways** to send content from a `.php` file to the Twig template.
The CMS picks the right one automatically:
1. **Echo / print** — everything you echo (or that sits outside `<?php ?>` tags)
is captured and placed as `{{ content }}` in the layout.
2. **Return** — if you `return` a string, that string is used as `{{ content }}`
(any echoed output is then ignored).
Example with echo:
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
// Get all pages
$allPages = $api->getAllPages();
// Result: ['index' => 'Home', 'about-us' => 'About Us', ...]
// Get a specific page
$page = $api->getPage('about-us');
// Result: ['title' => 'About Us', 'content' => '...', 'path' => 'about-us', 'layout' => 'full_content', 'metadata' => [...]]
// Get menu structure
$menu = $api->getMenu();
// Result: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
// Get config value (dot notation)
$siteTitle = $api->getConfig('site_title');
$searchEnabled = $api->getConfig('features.search_enabled', false);
?>
<h1>Hello <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
<p>Welcome to my page.</p>
```
## Available methods
Example with return:
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
return '<h1>Hello ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
```
> The frontmatter (`---` block) is stripped by the CMS before the PHP code
> runs. The layout key determines which Twig template surrounds the content.
## What you cannot do
- You **cannot** set your own Twig variables from a `.php` file. PHP content
only feeds the `{{ content }}` placeholder. Other template variables
(`menu`, `breadcrumb`, `page_title`, etc.) are set by the CMS based on
frontmatter and config — not by your PHP code.
- You **cannot** call a ContentAPI from the outside; the class is only
instantiated inside `parsePHP()` and is never reachable via a URL.
## Available variables in your PHP file
The following variables are available inside a `.php` content file:
| Variable | Type | Description |
|----------|------|-------------|
| `$api` | `ContentAPI` | Read-only access to CMS data |
| `$pageMetadata` | `array` | The frontmatter metadata of this file |
## ContentAPI methods
### Pages
- `getAllPages(): array` - All pages as `['path' => 'title']` pairs
- `getPage(string $path): ?array` - Specific page with `title`, `content`, `path`, `layout`, `metadata`
- `pageExists(string $path): bool` - Check if a page exists
- `getCurrentPageTitle(): string` - Title of current page
- `getCurrentPagePath(): string` - Path of current page
- `isHomepage(): bool` - Whether current page is the homepage
- `getAllPages(): array` All content entries (folders + files) as a list of `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` for files, `folder` for directories.
- `getPage(string $path): ?array` — A specific page; returns `title`, `content`, `path`, `layout`, `metadata`. `$path` may include an extension.
- `pageExists(string $path): bool` Check whether a page exists
- `getCurrentPageTitle(): string` Title of the current page
- `getCurrentPagePath(): string` Path of the current page
- `isHomepage(): bool` Whether the current page is the homepage
### Menu & Navigation
### Menu & navigation
- `getMenu(): array` - Hierarchical menu structure with `title`, `path`, `children`, `active`
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Build a URL for a page
- `getMenu(): array` Hierarchical menu structure with `title`, `path`, `children`, `active`
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` Build a URL
### Configuration
- `getConfig(string $key, mixed $default = null): mixed` - Config value via dot notation (e.g. `'features.search_enabled'`)
- `getSiteTitle(): string` - Site title from config
- `getConfig(string $key, mixed $default = null): mixed` Config value via dot notation (e.g. `'features.search_enabled'`)
- `getSiteTitle(): string` Site title from config
### Language
- `getCurrentLanguage(): string` - Current language code (e.g. `'nl'`)
- `getAvailableLanguages(): array` - Available languages (e.g. `['nl', 'en']`)
- `t(string $key): string` - Translate a language key
- `getCurrentLanguage(): string` Current language code (e.g. `'nl'`)
- `getAvailableLanguages(): array` Available languages
- `t(string $key): string` Translate a language key
### Search
- `getSearchResults(): array` - Search results (empty if not searching)
- `isSearching(): bool` - Whether a search is currently active
- `getSearchResults(): array` Search results (empty if not searching)
- `isSearching(): bool` Whether a search is currently active
## Example: Show recent pages
### Author
- `getPageAuthor(): array` — Author metadata from frontmatter (`author_name`, `author_email`, `created`)
## Examples
### Show recent pages
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
$pages = $api->getAllPages();
$entries = $api->getAllPages();
$currentLang = $api->getCurrentLanguage();
?>
<ul>
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
<?php foreach ($entries as $entry): ?>
<?php if ($entry['type'] === 'folder') continue; // skip folders ?>
<li>
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
<?= htmlspecialchars($title) ?>
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
<?= htmlspecialchars($entry['title']) ?>
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
</a>
</li>
<?php endforeach; ?>
</ul>
```
## Example: Using config values
### Use a config value
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
if ($api->getConfig('features.search_enabled', false)): ?>
@@ -89,6 +137,44 @@ if ($api->getConfig('features.search_enabled', false)): ?>
<?php endif; ?>
```
### Dynamic greeting based on language
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
$lang = $api->getCurrentLanguage();
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
?>
<h1><?= $greeting ?> to <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
```
## Choosing a layout
The frontmatter `layout` key determines which Twig template surrounds your
content. Available layouts are defined in `themes/<active-theme>/theme.json`
under the `template` section. For example:
```yaml
---
layout: sidebar-content
---
```
If you omit a layout, `config.default_template` is used, falling back to
`full_content`. The content always lands at the `{{ content }}` spot in that
template.
## Security
- Always use `htmlspecialchars()` for output of user-content or API data.
- PHP content files have access to the server filesystem — be careful with
`include`, `require` or `file_get_contents` on external paths. Stay inside
the `content/` directory.
- The ContentAPI is read-only; you cannot modify files or config through it.
## CMSAPI (for plugins)
Plugins use the `CMSAPI` class via `$this->api`. It offers similar methods:
@@ -108,12 +194,12 @@ $this->api->createUrl('about-us');
$this->api->translate('home');
```
Additionally, CMSAPI provides:
In addition, CMSAPI has:
- `getCurrentPage(): array` - Full page data
- `getCurrentPageUrl(): string` - URL of current page
- `getCurrentPageFileInfo(): ?array` - File info (created, modified)
- `getBreadcrumb(): string` - Breadcrumb HTML
- `executePhpFile(string $filePath): string` - Execute PHP file and capture output
- `getFileContent(string $filePath): string` - Get content from PHP/HTML/Markdown file
- `contentFileExists(string $filename): bool` - Check if file exists in content directory
- `getCurrentPage(): array` Full page data
- `getCurrentPageUrl(): string` URL of the current page
- `getCurrentPageFileInfo(): ?array` File info (created, modified)
- `getBreadcrumb(): string` Breadcrumb HTML
- `executePhpFile(string $filePath): string` Execute a PHP file and capture output
- `getFileContent(string $filePath): string` Get content from a PHP/HTML/Markdown file
- `contentFileExists(string $filename): bool` Check whether a file exists in the content directory
+36 -40
View File
@@ -1,13 +1,8 @@
# Content beheer
CodePress biedt twee weergaven voor content-beheer:
CodePress gebruikt een **boom weergave** (`/admin/content`) voor content-beheer: een bestandsbrowser zijbalk + CodeMirror editor, uniform met de plugin- en thema-editors. De oude lijst/tabel weergave is verwijderd.
1. **Boom weergave** (`/admin/content`) — standaard, bestandsbrowser zijbalk + CodeMirror editor (uniform met plugin/theme-editors)
2. **Lijst weergave** (`/admin/content-list`) — klassieke tabel met filter, upload, nieuwe map/bestand per map
Beide werken naast elkaar en gebruiken dezelfde content-map. Wissel tussen de weergaven via de "Lijst weergave"/"Boom weergave" knoppen rechtsboven. De boom weergave biedt dezelfde functionaliteit als de lijst weergave, maar dan met een geneste bestandsboom zijbalk zoals de plugin- en thema-editors — voor consistente editor-ervaring.
## Boom weergave (content)
## Content editor
### Bestandsbrowser zijbalk
- Toont de geneste bestandsboom van `content/`
@@ -19,6 +14,30 @@ Beide werken naast elkaar en gebruiken dezelfde content-map. Wissel tussen de we
### Bewerkbare bestandstypen
`.md` (Markdown), `.php` (PHP), `.html` (HTML) — consistent met de bestaande content-edit pagina.
Alle types ondersteunen `---` frontmatter (layout, created, edited, plugins). De CMS `parseMetadata()` haalt de frontmatter eruit vóór verwerking. Bij PHP bestanden wordt de frontmatter uit de output verwijderd.
#### PHP content bestanden
PHP bestanden kunnen op twee manieren content leveren:
1. **Echo** (output buffering): alles wat `echo`d of outside `<?php` staat wordt als content gebruikt
2. **Return** (callback stijl): `return '<h1>Hallo</h1>';` — de geretourneerde string wordt als content gebruikt
In PHP bestanden is de `ContentAPI` beschikbaar via `$api`:
```php
<?php
$menu = $api->getMenu();
$pages = $api->getAllPages();
$config = $api->getConfig('site_title');
return '<h1>' . htmlspecialchars($config) . '</h1>';
```
#### Frontend URL's en bestandsnamen
De CMS zoekt bestanden in volgorde: `.md``.php``.html`. Als bestanden dezelfde naam hebben (bijv. `test.md` en `test.php`), opent `/nl/test` altijd `test.md`. Om een specifiek bestandstype te openen, voeg de extensie toe aan de URL:
- `/nl/test` → opent `test.md` (of `.php` / `.html` als `.md` niet bestaat)
- `/nl/test.php` → opent `test.php` (expliciete extensie)
- `/nl/test.html` → opent `test.html` (expliciete extensie)
De preview link in de editor gebruikt automatisch de extensie voor niet-`.md` bestanden.
### Image-grootte instellen (Markdown)
In de markdown editor-toolbar staat een "Afbeelding grootte" knop (expand-icoon):
1. Selecteer een afbeelding in de editor in markdown formaat `![alt](url)`
@@ -41,7 +60,7 @@ Images in `content/` worden via de `/-media/` endpoint geserveerd (content/ staa
- Geef een pad op binnen content (bijv. `nl.pagina` of `blog/nl.post`)
- Kies het bestandstype (Markdown/PHP/HTML)
- Submappen worden automatisch aangemaakt
- Frontmatter met `layout`, `author_name`, `author_email`, `created` wordt automatisch gegenereerd
- Frontmatter met `layout`, `created`, `edited` wordt automatisch gegenereerd
### Bestand uploaden
- Klik op **Upload** om bestanden naar `content/` te uploaden
@@ -62,27 +81,22 @@ Images in `content/` worden via de `/-media/` endpoint geserveerd (content/ staa
Op de editor-pagina kun je de layout kiezen uit de layouts gedefinieerd in `theme.json` (template mapping). De geselecteerde layout wordt opgeslagen in de frontmatter `layout:` key.
### Plugins op pagina's
- Content plugins (uit `plugin.json` met `type: "content"`) verschijnen in de plugin selectie
- Kies welke plugins op de pagina draaien
- Alleen actieve **content plugins** (uit `plugin.json` met `type: "content"`) verschijnen in de plugin multiselect; system plugins (zoals Statistics/Logs/Dashboard) zijn uitgesloten
- Kies via de multiselect welke plugins op de pagina draaien (gebruik Ctrl/Cmd+klik voor meerdere)
- De plugin selectie wordt opgeslagen in de frontmatter `plugins:` key
### Git / Backup integratie (Fase 5)
Bovenaan de content-editor staan backup- en git-acties:
- **Git init**: initialiseert een git repository in `content/` (als er nog geen is)
- **Commit**: committed alle niet-committed wijzigingen (alleen als er een git repo is en er wijzigingen zijn)
- **Backup**: link naar de backup-pagina (`/admin/content-backup`) voor ZIP backup/restore
- De git status badge toont de huidige branch, of er niet-committed wijzigingen zijn, en de laatste commit
### Backup integratie
Bovenaan de content-editor staat een **Backup** knop die naar de backup-pagina (`/admin/content-backup`) leidt voor ZIP backup/restore en (indien beschikbaar) git versiebeheer. Git integratie in de editor-zijbalk is verwijderd; git wordt later een systeem plugin.
### Frontmatter
De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin selectie:
De editor toont de **Aangemaakt** en **Bewerkt** timestamps (read-only, automatisch bijgewerkt bij elke opslag). De `edited:` waarde wordt bij elke save vernieuwd.
```markdown
---
layout: left_sidebar
author_name: Admin
author_email: admin@example.com
created: 2026-08-19 10:30:25
edited: 2026-08-20 14:22:01
plugins: HTMLBlock, Navigation
---
@@ -91,24 +105,6 @@ plugins: HTMLBlock, Navigation
Content...
```
## Lijst weergave (content)
### Bestanden beheren
- **Nieuwe map** — Mappen structuur aanmaken
- **Nieuw bestand** — Pagina aanmaken (`.md`, `.php`, `.html`)
- **Bewerken** — Bestaande content wijzigen in CodeMirror editor
- **Hernoemen** — Bestands- of mapnamen aanpassen
- **Verplaatsen** — Content verplaatsen naar andere map
- **Verwijderen** — Content verwijderen
- **Map hernoemen** — Map naam wijzigen
### Editor (content-edit)
- **CodeMirror** met syntax highlighting (Markdown, PHP, HTML)
- **Toolbar** voor snel Markdown invoeren
- **Sneltoetsen**: Ctrl+S (opslaan), Ctrl+N (nieuw)
## Frontmatter
De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin selectie:
@@ -116,9 +112,9 @@ De editor werkt de frontmatter live bij bij wijzigingen van layout of plugin sel
```markdown
---
layout: left_sidebar
plugins:
- HTMLBlock
- Navigation
created: 2026-08-19 10:30:25
edited: 2026-08-20 14:22:01
plugins: HTMLBlock, Navigation
---
# Pagina titel
+1
View File
@@ -53,6 +53,7 @@ Via **Bewerken** (potlood-icoon) in het thema-overzicht open je de thema-editor
### Media invoegen in editor
- De media-knop in de editor-toolbar opent de media-modal
- Media wordt getoond als een collapsible **bestandsboom** (folders inklappen/uitklappen), gelijk aan de content/plugin/theme editors
- In thema-context scant deze `themes/<naam>/assets/` (via `/admin/media-list?theme=<naam>`)
- Snippet-formaat depends op bestandstype: markdown → `![alt](url)`, html/php → `<img src=...>`, andere → ruwe URL
+136 -49
View File
@@ -1,84 +1,132 @@
# Content API
De Content API is beschikbaar in PHP content bestanden (`.php`) en biedt een veilige, read-only interface tot CMS data.
PHP content bestanden (`.php`) draaien binnen het CMS en hebben toegang tot een
veilige, read-only API via de variabele `$api`. De output van zo'n bestand wordt
automatisch in de actieve Twig layout geplaatst op de plek van `{{ content }}`.
## Gebruik in PHP content bestanden
## Hoe je content doorgeeft aan de Twig template
Er zijn **twee manieren** om content vanuit een `.php` bestand naar het Twig
template te sturen. De CMS kiest automatisch de juiste:
1. **Echo / print** — alles wat je echoot (of wat buiten `<?php ?>` tags staat)
wordt opgevangen en als `{{ content }}` in de layout geplaatst.
2. **Return** — als je `return` gebruikt met een string, wordt die string als
`{{ content }}` gebruikt (echt output wordt dan genegeerd).
Voorbeeld met echo:
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
// Alle pagina's ophalen
$allPages = $api->getAllPages();
// Resultaat: ['index' => 'Home', 'over-ons' => 'Over ons', ...]
// Specifieke pagina ophalen
$page = $api->getPage('over-ons');
// Resultaat: ['title' => 'Over ons', 'content' => '...', 'path' => 'over-ons', 'layout' => 'full_content', 'metadata' => [...]]
// Menu structuur ophalen
$menu = $api->getMenu();
// Resultaat: [['title' => 'Home', 'path' => 'index', 'type' => 'file', 'active' => true], ...]
// Config waarde ophalen (dot notatie)
$siteTitle = $api->getConfig('site_title');
$searchEnabled = $api->getConfig('features.search_enabled', false);
?>
<h1>Hallo <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
<p>Welkom op mijn pagina.</p>
```
## Beschikbare methods
Voorbeeld met return:
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
return '<h1>Hallo ' . htmlspecialchars($api->getSiteTitle()) . '</h1>';
```
> De frontmatter (`---` blok) wordt door het CMS gestript vóórdat de PHP code
> uitgevoerd wordt. De layout key bepaalt welke Twig template de content omringt.
## Wat kun je níét doen
- Je kunt **geen** eigen Twig variabelen zetten vanuit een `.php` bestand.
PHP content leverde alleen de `{{ content }}` placeholder. Andere template
variabelen (`menu`, `breadcrumb`, `page_title`, etc.) worden door het CMS
vastgesteld op basis van de frontmatter en config — niet door je PHP code.
- Je kunt **geen** van buitenaf een ContentAPI aanroepen; de class wordt alleen
binnen `parsePHP()` geïnstantieerd en is nooit via een URL bereikbaar.
## Beschikbare variabelen in je PHP bestand
Binnen een `.php` content bestand zijn de volgende variabelen beschikbaar:
| Variabele | Type | Omschrijving |
|-----------|------|--------------|
| `$api` | `ContentAPI` | Read-only toegang tot CMS data |
| `$pageMetadata` | `array` | De frontmatter metadata van dit bestand |
## ContentAPI methods
### Pagina's
- `getAllPages(): array` - Alle pagina's als `['pad' => 'titel']` pairs
- `getPage(string $path): ?array` - Specifieke pagina met `title`, `content`, `path`, `layout`, `metadata`
- `pageExists(string $path): bool` - Controleer of een pagina bestaat
- `getCurrentPageTitle(): string` - Titel van huidige pagina
- `getCurrentPagePath(): string` - Pad van huidige pagina
- `isHomepage(): bool` - Of huidige pagina de homepage is
- `getAllPages(): array` Alle content entries (mappen + bestanden) als een list van `['path' => ..., 'title' => ..., 'type' => ...]`. `type` is `md`/`php`/`html` voor bestanden, `folder` voor mappen.
- `getPage(string $path): ?array` Specifieke pagina; levert `title`, `content`, `path`, `layout`, `metadata`. `$path` mag een extensie bevatten.
- `pageExists(string $path): bool` Controleer of een pagina bestaat
- `getCurrentPageTitle(): string` Titel van de huidige pagina
- `getCurrentPagePath(): string` Pad van de huidige pagina
- `isHomepage(): bool` Of de huidige pagina de homepage is
### Menu & Navigatie
### Menu & navigatie
- `getMenu(): array` - Hiërarchische menu structuur met `title`, `path`, `children`, `active`
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` - Bouw een URL voor een pagina
- `getMenu(): array` Hiërarchische menu structuur met `title`, `path`, `children`, `active`
- `buildUrl(string $page = 'index', ?string $lang = null, array $params = []): string` Bouw een URL
### Configuratie
- `getConfig(string $key, mixed $default = null): mixed` - Config waarde via dot notatie (bijv. `'features.search_enabled'`)
- `getSiteTitle(): string` - Site titel uit config
- `getConfig(string $key, mixed $default = null): mixed` Config waarde via dot notatie (bijv. `'features.search_enabled'`)
- `getSiteTitle(): string` Site titel uit config
### Taal
- `getCurrentLanguage(): string` - Huidige taal code (bijv. `'nl'`)
- `getAvailableLanguages(): array` - Beschikbare talen (bijv. `['nl', 'en']`)
- `t(string $key): string` - Vertaal een language key
- `getCurrentLanguage(): string` Huidige taal code (bijv. `'nl'`)
- `getAvailableLanguages(): array` Beschikbare talen
- `t(string $key): string` Vertaal een language key
### Zoeken
- `getSearchResults(): array` - Zoekresultaten (leeg als niet aan het zoeken)
- `isSearching(): bool` - Of er momenteel gezocht wordt
- `getSearchResults(): array` Zoekresultaten (leeg als niet aan het zoeken)
- `isSearching(): bool` Of er momenteel gezocht wordt
## Voorbeeld: Recentste pagina's tonen
### Auteur
- `getPageAuthor(): array` — Auteur metadata uit frontmatter (`author_name`, `author_email`, `created`)
## Voorbeelden
### Recente pagina's tonen
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
$pages = $api->getAllPages();
$entries = $api->getAllPages();
$currentLang = $api->getCurrentLanguage();
?>
<ul>
<?php foreach (array_slice($pages, 0, 5, true) as $path => $title): ?>
<?php foreach ($entries as $entry): ?>
<?php if ($entry['type'] === 'folder') continue; // sla mappen over ?>
<li>
<a href="/<?= $currentLang ?>/<?= htmlspecialchars($path) ?>">
<?= htmlspecialchars($title) ?>
<a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($entry['path']) ?>">
<?= htmlspecialchars($entry['title']) ?>
<small>(<?= htmlspecialchars($entry['type']) ?>)</small>
</a>
</li>
<?php endforeach; ?>
</ul>
```
## Voorbeeld: Config waarde gebruiken
### Config waarde gebruiken
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
if ($api->getConfig('features.search_enabled', false)): ?>
@@ -89,9 +137,48 @@ if ($api->getConfig('features.search_enabled', false)): ?>
<?php endif; ?>
```
### Dynamische begroeting op basis van taal
```php
---
layout: full_content
---
<?php
/** @var ContentAPI $api */
$lang = $api->getCurrentLanguage();
$greeting = $lang === 'nl' ? 'Welkom' : 'Welcome';
?>
<h1><?= $greeting ?> op <?= htmlspecialchars($api->getSiteTitle()) ?></h1>
```
## Layout kiezen
De frontmatter `layout` key bepaalt welke Twig template je content omringt. De
beschikbare layouts staan in `themes/<actief-thema>/theme.json` onder de
`template` sectie. Bijvoorbeeld:
```yaml
---
layout: sidebar-content
---
```
Als je geen layout opgeeft, wordt `config.default_template` gebruikt, met als
fallback `full_content`. De content komt altijd terecht op de `{{ content }}`
plek in die template.
## Veiligheid
- Gebruik altijd `htmlspecialchars()` voor output van user-content of API data.
- PHP content bestanden hebben toegang tot het bestandssysteem van de server —
wees voorzichtig met `include`, `require` of `file_get_contents` op externe
paden. Blijf binnen de `content/` map.
- De ContentAPI is read-only; je kunt er geen bestanden of config mee wijzigen.
## CMSAPI (voor plugins)
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare methods:
Plugins gebruiken de `CMSAPI` class via `$this->api`. Deze biedt vergelijkbare
methods:
```php
$this->api->getCurrentPageTitle();
@@ -110,10 +197,10 @@ $this->api->translate('home');
Daarnaast heeft de CMSAPI:
- `getCurrentPage(): array` - Volledige pagina data
- `getCurrentPageUrl(): string` - URL van huidige pagina
- `getCurrentPageFileInfo(): ?array` - Bestandsinfo (created, modified)
- `getBreadcrumb(): string` - Breadcrumb HTML
- `executePhpFile(string $filePath): string` - Voer PHP bestand uit en vang output op
- `getFileContent(string $filePath): string` - Haal content uit PHP/HTML/Markdown bestand
- `contentFileExists(string $filename): bool` - Controleer of bestand bestaat in content map
- `getCurrentPage(): array` Volledige pagina data
- `getCurrentPageUrl(): string` URL van huidige pagina
- `getCurrentPageFileInfo(): ?array` Bestandsinfo (created, modified)
- `getBreadcrumb(): string` Breadcrumb HTML
- `executePhpFile(string $filePath): string` Voer PHP bestand uit en vang output op
- `getFileContent(string $filePath): string` Haal content uit PHP/HTML/Markdown bestand
- `contentFileExists(string $filename): bool` Controleer of bestand bestaat in content map
+2
View File
@@ -128,6 +128,8 @@ return [
'visible_plugins' => 'Sichtbare Plugins',
'new_page' => 'Neue Seite',
'file_type' => 'Dateityp',
'created' => 'Erstellt',
'edited' => 'Bearbeitet',
// Content dir form
'rename_folder' => 'Ordner umbenennen',
+1
View File
@@ -326,6 +326,7 @@ return [
'cannot_change_own_role' => 'You cannot change your own role.',
'cannot_change_password_role' => 'Changing passwords is not allowed in this role.',
'created' => 'Created',
'edited' => 'Edited',
// Plugin config
'plugin_settings' => 'Settings',
+1
View File
@@ -328,6 +328,7 @@ return [
'cannot_change_own_role' => 'Je kunt je eigen rol niet wijzigen.',
'cannot_change_password_role' => 'Wachtwoord wijzigen is niet toegestaan in deze rol.',
'created' => 'Aangemaakt',
'edited' => 'Bewerkt',
// Plugin config
'plugin_settings' => 'Instellingen',
+195 -100
View File
@@ -159,6 +159,59 @@ function isProtectedPlugin(string $pluginName): bool {
return in_array($pluginName, getProtectedPlugins(), true);
}
/**
* Get the list of content-type plugins (name => [name, title, type]).
* Used by the content editor "visible plugins" selector so only content
* plugins (not system plugins like Statistics/Logs/Dashboard) are offered.
*
* Type resolution mirrors handlePlugins:
* 1. default 'content'
* 2. plugin.json 'type' override
* 3. regex from <Plugin>.php 'type' => '...' (fallback when plugin.json has no type)
*
* @param string $pluginsDir Absolute path to the plugins directory
* @return array<int,array{name:string,title:string,type:string}> Content-type plugins only
*/
function getContentPlugins(string $pluginsDir): array
{
$plugins = [];
if (!is_dir($pluginsDir)) {
return $plugins;
}
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) {
$pluginName = basename($pluginDir);
if ($pluginName === '.' || $pluginName === '..') continue;
$pluginJson = $pluginDir . '/plugin.json';
$type = 'content';
$title = ucfirst($pluginName);
if (file_exists($pluginJson)) {
$data = json_decode(file_get_contents($pluginJson), true);
if (is_array($data)) {
if (isset($data['type'])) $type = $data['type'];
if (isset($data['name'])) $title = $data['name'];
elseif (isset($data['title'])) $title = $data['title'];
}
}
if ($type === 'content') {
// Fallback: detect type from plugin PHP when plugin.json has no explicit type
$pluginFile = $pluginDir . '/' . $pluginName . '.php';
if (file_exists($pluginJson) && !isset($data['type']) && file_exists($pluginFile)) {
$source = file_get_contents($pluginFile);
if (preg_match("/'type'\s*=>\s*'([^']+)'/", $source, $m)) {
$type = $m[1];
}
}
}
if ($type === 'content') {
$plugins[] = ['name' => $pluginName, 'title' => $title, 'type' => $type];
}
}
usort($plugins, function ($a, $b) { return strcasecmp($a['name'], $b['name']); });
return $plugins;
}
// Public routes (no auth required)
if ($route === 'login') {
$error = '';
@@ -274,8 +327,9 @@ switch ($route) {
break;
case 'content-list':
handleContent($auth, $appConfig, $twig, $user, $csrf, $siteConfig);
break;
// Lijst weergave is verwijderd; redirect naar de boom-editor
header('Location: /admin/content');
exit;
case 'content-files':
// Backward compat: redirect to the unified content editor
@@ -737,13 +791,21 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
} else {
$newReal = $parentReal . '/' . basename($newFullPath);
if (file_exists($newReal)) {
$message = 'Bestand bestaat al.';
$message = 'Bestand met dit type bestaat al.';
$messageType = 'danger';
} else {
// Build frontmatter with author metadata (zoals handleContentNew)
// Build frontmatter with layout/created/edited timestamps.
// All editable types (md/php/html) support --- frontmatter; the CMS
// parseMetadata() extracts it and parsePHP() strips it from output.
$now = date('Y-m-d H:i:s');
$frontmatter = "---\nlayout: full_content\nauthor_name: " . ($user['author_name'] ?? $user['username'] ?? '') . "\nauthor_email: " . ($user['author_email'] ?? '') . "\ncreated: " . $now . "\n---\n\n";
$stub = $ext === 'md' ? $frontmatter . "# Nieuwe pagina\n\n" : ($ext === 'php' ? "<?php\n" : "<h1>Nieuwe pagina</h1>\n");
$frontmatter = "---\nlayout: full_content\ncreated: " . $now . "\nedited: " . $now . "\n---\n\n";
if ($ext === 'md') {
$stub = $frontmatter . "# Nieuwe pagina\n\n";
} elseif ($ext === 'php') {
$stub = $frontmatter . "<?php\n";
} else {
$stub = $frontmatter . "<h1>Nieuwe pagina</h1>\n";
}
$written = @file_put_contents($newReal, $stub);
if ($written !== false) {
adminLog($config, 'info', $user['username'] . ' creëerde content bestand ' . $newRel);
@@ -870,23 +932,57 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
// Save the currently selected file
$content = $_POST['content'] ?? '';
$layout = $_POST['layout'] ?? '';
$newFilename = trim($_POST['new_filename'] ?? '');
if ($realPath && is_file($realPath)) {
$ext = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
if (in_array($ext, $editableExts, true)) {
if ($layout && $ext === 'md') {
if ($layout) {
$content = updateContentFrontmatter($content, 'layout', $layout);
}
$plugins = isset($_POST['plugins']) && is_array($_POST['plugins'])
? implode(', ', array_map('trim', $_POST['plugins']))
: '';
if ($ext === 'md') {
$content = updateContentFrontmatter($content, 'plugins', $plugins);
}
// Stamp the last edit time so the editor can show created/edited
$content = updateContentFrontmatter($content, 'edited', date('Y-m-d H:i:s'));
// Rename if the filename changed
if ($newFilename !== '') {
$cleanName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename);
$cleanName = preg_replace('/\.(md|php|html)$/i', '', $cleanName);
$newFull = dirname($realPath) . '/' . $cleanName . '.' . $ext;
if ($newFull !== $realPath) {
if (file_exists($newFull)) {
$message = 'Bestandsnaam bestaat al.';
$messageType = 'danger';
} else {
backupContentFile($realPath);
file_put_contents($realPath, $content);
rename($realPath, $newFull);
$realPath = $newFull;
$relFile = trim(dirname($relFile), '.\\/') ? trim(dirname($relFile), '.\\/') . '/' . $cleanName . '.' . $ext : $cleanName . '.' . $ext;
$renamedTo = $relFile;
adminLog($config, 'info', $user['username'] . ' hernoemde content naar ' . $relFile);
$message = 'Bestand opgeslagen en hernoemd.';
$messageType = 'success';
}
}
}
if ($message !== 'Bestandsnaam bestaat al.') {
backupContentFile($realPath);
file_put_contents($realPath, $content);
if ($message === '') {
adminLog($config, 'info', $user['username'] . ' bewerkte content ' . $relFile);
$message = 'Bestand opgeslagen.';
$messageType = 'success';
}
// If renamed, redirect to the new URL so the browser URL stays in sync
if (isset($renamedTo)) {
header('Location: /admin/content?file=' . urlencode($renamedTo) . '&msg=' . urlencode($message) . '&msgtype=success');
exit;
}
}
} else {
$message = 'Dit bestandstype kan niet bewerkt worden.';
$messageType = 'danger';
@@ -917,9 +1013,11 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
$fileContent = '';
$fileExt = '';
$fileName = '';
$fileBaseName = '';
if ($realPath && is_file($realPath)) {
$fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
$fileName = basename($realPath);
$fileBaseName = basename($realPath, '.' . $fileExt);
if (in_array($fileExt, $editableExts, true)) {
$isEditable = true;
$fileContent = file_get_contents($realPath);
@@ -943,15 +1041,17 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
}
}
// Available plugins for the plugins checkbox group
// Available content plugins for the plugins multiselect (only active
// content-type plugins, never system plugins like Statistics/Logs).
$pluginsDir = $config['plugins_dir'];
$contentPlugins = getContentPlugins($pluginsDir);
// Keep only enabled plugins so the selector reflects what actually runs.
// enabled_plugins lives in config.json ($siteConfig), not in app.php.
$enabledPlugins = $siteConfig['enabled_plugins'] ?? [];
$availablePlugins = [];
if (is_dir($pluginsDir)) {
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) {
$pluginName = basename($pluginDir);
if ($pluginName !== '.' && $pluginName !== '..' && file_exists($pluginDir . '/plugin.json')) {
$availablePlugins[] = $pluginName;
}
foreach ($contentPlugins as $p) {
if (in_array($p['name'], $enabledPlugins, true)) {
$availablePlugins[] = $p;
}
}
@@ -960,26 +1060,11 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
$currentPlugins = $isEditable ? extractFrontmatterValue($fileContent, 'plugins') : '';
$selectedPlugins = array_map('trim', explode(',', $currentPlugins));
if ($selectedPlugins === ['']) $selectedPlugins = [];
$currentAuthorName = $isEditable ? extractFrontmatterValue($fileContent, 'author_name') : '';
$currentAuthorEmail = $isEditable ? extractFrontmatterValue($fileContent, 'author_email') : '';
$currentCreated = $isEditable ? extractFrontmatterValue($fileContent, 'created') : '';
$currentEdited = $isEditable ? extractFrontmatterValue($fileContent, 'edited') : '';
$currentLang = $relFile ? extractLanguagePrefix($relFile) : 'nl';
$fileDir = $relFile ? trim(dirname($relFile), '.\\/') : '';
// Git status for Fase 5 (content git integratie in zijbalk)
$gitStatus = ['available' => false, 'initialized' => false, 'dirty' => false, 'branch' => '', 'ahead' => 0, 'behind' => 0, 'lastCommit' => ''];
$gitDir = $realContentDir . '/.git';
$gitStatus['available'] = $is_dir = is_dir($gitDir);
if ($gitStatus['available']) {
$gitStatus['initialized'] = true;
$branch = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git rev-parse --abbrev-ref HEAD 2>/dev/null');
$gitStatus['branch'] = trim($branch ?? '');
$dirty = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git status --porcelain 2>/dev/null');
$gitStatus['dirty'] = trim($dirty ?? '') !== '';
$lastCommit = @shell_exec('cd ' . escapeshellarg($realContentDir) . ' && git log -1 --format="%h %s (%cr)" 2>/dev/null');
$gitStatus['lastCommit'] = trim($lastCommit ?? '');
}
$route = 'content-files';
echo $twig->render('pages/content-files.twig', [
@@ -988,7 +1073,9 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
'csrf_token' => $csrf,
'relFile' => $relFile,
'fileDir' => $fileDir,
'selectedDir' => $fileDir,
'fileName' => $fileName,
'fileBaseName' => $fileBaseName,
'fileExt' => $fileExt,
'fileContent' => $fileContent,
'isEditable' => $isEditable,
@@ -1001,10 +1088,8 @@ function handleContentFiles($auth, $config, $twig, $user, $csrf, $siteConfig): v
'availablePlugins' => $availablePlugins,
'selectedPlugins' => $selectedPlugins,
'currentLang' => $currentLang,
'currentAuthorName' => $currentAuthorName,
'currentAuthorEmail' => $currentAuthorEmail,
'currentCreated' => $currentCreated,
'gitStatus' => $gitStatus,
'currentEdited' => $currentEdited,
'sidebar_color' => getSidebarColor($config),
'needs_editor' => $isEditable,
'message' => $message,
@@ -1232,6 +1317,16 @@ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void
break;
}
}
// Optional rename: new filename (without extension, extension is preserved)
$newName = trim($_POST['new_name'] ?? '');
$ext = strtolower(pathinfo($relFile, PATHINFO_EXTENSION));
if ($newName !== '') {
$newName = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newName);
// Strip any extension the user may have typed (we preserve the original)
$newName = preg_replace('/\.(md|php|html)$/i', '', $newName);
} else {
$newName = basename($relFile, '.' . $ext);
}
if ($message === '') {
$destDir = $dest === '' ? $realContentDir : $realContentDir . '/' . $dest;
$realDestDir = realpath($destDir);
@@ -1239,18 +1334,24 @@ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void
$message = 'Doelmap bestaat niet.';
$messageType = 'danger';
} else {
$newPath = $realDestDir . '/' . basename($realPath);
$newFileName = $newName . '.' . $ext;
$newPath = $realDestDir . '/' . $newFileName;
if (strtolower($newPath) === strtolower($realPath)) {
// No change
header('Location: /admin/content?file=' . urlencode($relFile) . '&msg=' . urlencode('Geen wijziging.') . '&msgtype=info');
exit;
}
if (file_exists($newPath)) {
$message = 'Bestand bestaat al op de bestemming.';
$messageType = 'danger';
} elseif (rename($realPath, $newPath)) {
adminLog($config, 'info', $user['username'] . ' verplaatste content/' . $relFile . ' naar content/' . ($dest ? $dest . '/' : '') . basename($relFile));
$newRel = $dest === '' ? basename($relFile) : $dest . '/' . basename($relFile);
header('Location: /admin/content?file=' . urlencode($newRel) . '&msg=' . urlencode('Bestand verplaatst.') . '&msgtype=success');
$newRel = $dest === '' ? $newFileName : $dest . '/' . $newFileName;
adminLog($config, 'info', $user['username'] . ' hernoemde/verplaatste content/' . $relFile . ' naar content/' . $newRel);
header('Location: /admin/content?file=' . urlencode($newRel) . '&msg=' . urlencode('Bestand hernoemd/verplaatst.') . '&msgtype=success');
exit;
} else {
$err = error_get_last();
$message = 'Verplaatsen mislukt: ' . ($err['message'] ?? 'onbekend');
$message = 'Hernoemen/verplaatsen mislukt: ' . ($err['message'] ?? 'onbekend');
$messageType = 'danger';
}
}
@@ -1263,7 +1364,8 @@ function handleContentFileMove($auth, $config, $twig, $user, $csrf): void
'route' => 'content-file-move',
'csrf_token' => $csrf,
'relFile' => $relFile,
'itemName' => basename($relFile),
'itemName' => basename($relFile, '.' . pathinfo($relFile, PATHINFO_EXTENSION)),
'itemExt' => '.' . pathinfo($relFile, PATHINFO_EXTENSION),
'itemDir' => trim(dirname($relFile), '.\\/'),
'directories' => $directories,
'sidebar_color' => getSidebarColor($config),
@@ -1475,7 +1577,7 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
$realPath = realpath($filePath);
$realContentDir = realpath($contentDir);
if (!$realPath || !$realContentDir || strpos($realPath, $realContentDir) !== 0) {
header('Location: /admin/content-list');
header('Location: /admin/content');
exit;
}
@@ -1487,28 +1589,6 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
// Handle rename
$newFilename = trim($_POST['filename'] ?? '');
$wasRenamed = false;
if (!empty($newFilename)) {
$newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $newFilename);
$newFilename .= '.' . $fileExt;
$parentDir = dirname($filePath);
$newFilePath = $parentDir . '/' . $newFilename;
$realParentDir = realpath($parentDir);
if ($realParentDir && strpos($realParentDir, $realContentDir) === 0) {
if ($newFilePath !== $filePath && !file_exists($newFilePath)) {
rename($filePath, $newFilePath);
$wasRenamed = true;
adminLog($config, 'info', $user['username'] . ' hernoemde ' . basename($filePath) . ' naar ' . $newFilename);
$filePath = $newFilePath;
$newFile = dirname($file) . '/' . $newFilename;
$file = ltrim($newFile, './');
}
}
}
// Save content only for editable files
if ($isEditable) {
$content = $_POST['content'] ?? '';
@@ -1520,11 +1600,10 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
? implode(', ', array_map('trim', $_POST['plugins']))
: '';
$content = updateContentFrontmatter($content, 'plugins', $plugins);
$content = updateContentFrontmatter($content, 'edited', date('Y-m-d H:i:s'));
backupContentFile($filePath);
file_put_contents($filePath, $content);
if (!$wasRenamed) {
adminLog($config, 'info', $user['username'] . ' bewerkte ' . basename($filePath));
}
$message = 'Bestand opgeslagen.';
$messageType = 'success';
}
@@ -1532,12 +1611,12 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
}
$fileName = basename($filePath);
$fileBaseName = basename($filePath, '.' . $fileExt);
$fileContent = $isEditable ? file_get_contents($filePath) : '';
$currentLayout = extractFrontmatterValue($fileContent, 'layout');
$currentPlugins = extractFrontmatterValue($fileContent, 'plugins');
$currentAuthorName = extractFrontmatterValue($fileContent, 'author_name');
$currentAuthorEmail = extractFrontmatterValue($fileContent, 'author_email');
$currentCreated = extractFrontmatterValue($fileContent, 'created');
$currentEdited = extractFrontmatterValue($fileContent, 'edited');
$selectedPlugins = array_map('trim', explode(',', $currentPlugins));
if ($selectedPlugins === ['']) $selectedPlugins = [];
@@ -1569,15 +1648,15 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
}
}
// Get available plugins
// Get available content plugins (only enabled content-type plugins).
// enabled_plugins lives in config.json ($siteConfig), not in app.php.
$pluginsDir = $config['plugins_dir'];
$contentPlugins = getContentPlugins($pluginsDir);
$enabledPlugins = $siteConfig['enabled_plugins'] ?? [];
$availablePlugins = [];
if (is_dir($pluginsDir)) {
foreach (glob($pluginsDir . '*', GLOB_ONLYDIR) as $pluginDir) {
$pluginName = basename($pluginDir);
if ($pluginName !== '.' && $pluginName !== '..' && file_exists($pluginDir . '/plugin.json')) {
$availablePlugins[] = $pluginName;
}
foreach ($contentPlugins as $p) {
if (in_array($p['name'], $enabledPlugins, true)) {
$availablePlugins[] = $p;
}
}
@@ -1592,6 +1671,7 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
'file' => $file,
'fileDir' => $fileDir,
'fileName' => $fileName,
'fileBaseName' => $fileBaseName,
'fileExt' => $fileExt,
'fileContent' => $fileContent,
'isEditable' => $isEditable,
@@ -1602,9 +1682,8 @@ function handleContentEdit($auth, $config, $twig, $user, $csrf, $siteConfig): vo
'availablePlugins' => $availablePlugins,
'selectedPlugins' => $selectedPlugins,
'currentLang' => $currentLang,
'currentAuthorName' => $currentAuthorName,
'currentAuthorEmail' => $currentAuthorEmail,
'currentCreated' => $currentCreated,
'currentEdited' => $currentEdited,
'sidebar_color' => getSidebarColor($config),
'needs_editor' => $isEditable,
'message' => $message,
@@ -1645,28 +1724,35 @@ function handleContentNew($auth, $config, $twig, $user, $csrf, $siteConfig): voi
$messageType = 'danger';
} else {
$filename = trim($_POST['filename'] ?? '');
$ext = $_POST['extension'] ?? 'md';
$ext = strtolower(trim($_POST['extension'] ?? 'md'));
if (!in_array($ext, ['md', 'php', 'html'], true)) $ext = 'md';
$layout = $_POST['layout'] ?? $themeDefaultLayout;
if (!empty($filename)) {
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $filename);
$filename .= '.' . $ext;
$dest = rtrim($fullPath, '/') . '/' . $filename;
if (!file_exists($dest)) {
// Build frontmatter with layout and author metadata
// Build frontmatter with layout and created/edited timestamps.
// All editable types (md/php/html) support --- frontmatter.
$now = date('Y-m-d H:i:s');
$frontmatter = "---\n";
$frontmatter .= "layout: " . $layout . "\n";
$frontmatter .= "author_name: " . ($user['author_name'] ?? $user['username'] ?? '') . "\n";
$frontmatter .= "author_email: " . ($user['author_email'] ?? '') . "\n";
$frontmatter .= "created: " . $now . "\n";
$frontmatter .= "edited: " . $now . "\n";
$frontmatter .= "---\n\n";
if ($ext === 'php') {
$content = $frontmatter . "<?php\n";
} elseif ($ext === 'html') {
$content = $frontmatter . "<h1>Nieuwe pagina</h1>\n";
} else {
$content = $frontmatter . "# Nieuwe pagina\n\n";
}
file_put_contents($dest, $content);
adminLog($config, 'info', $user['username'] . ' creëerde ' . $filename);
header('Location: /admin/content-edit?file=' . urlencode($dir . '/' . $filename));
exit;
} else {
$message = 'Bestand bestaat al.';
$message = 'Bestand met dit type bestaat al.';
$messageType = 'danger';
}
}
@@ -1695,13 +1781,13 @@ function handleContentNew($auth, $config, $twig, $user, $csrf, $siteConfig): voi
function handleContentDelete($auth, $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /admin/content-list');
header('Location: /admin/content');
exit;
}
$user = $auth->getCurrentUser();
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
header('Location: /admin/content-list');
header('Location: /admin/content');
exit;
}
@@ -1715,20 +1801,20 @@ function handleContentDelete($auth, $config): void
adminLog($config, 'info', $user['username'] . ' verwijderde ' . $file);
}
header('Location: /admin/content-list?dir=' . urlencode(dirname($file)));
header('Location: /admin/content?dir=' . urlencode(dirname($file)));
exit;
}
function handleContentDirCreate($auth, $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /admin/content-list');
header('Location: /admin/content');
exit;
}
$user = $auth->getCurrentUser();
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
header('Location: /admin/content-list');
header('Location: /admin/content');
exit;
}
@@ -1746,7 +1832,7 @@ function handleContentDirCreate($auth, $config): void
}
}
header('Location: /admin/content-list?dir=' . urlencode($subdir));
header('Location: /admin/content?dir=' . urlencode($subdir));
exit;
}
@@ -1773,7 +1859,7 @@ function handleContentDirRename($auth, $config, $twig, $user, $csrf): void
if (!file_exists($newPath) && $newPath !== $fullPath) {
rename($fullPath, $newPath);
adminLog($config, 'info', $user['username'] . ' hernoemde map ' . basename($dir) . ' naar ' . $newName);
header('Location: /admin/content-list?dir=' . urlencode(dirname($dir) . '/' . $newName));
header('Location: /admin/content?dir=' . urlencode(dirname($dir) . '/' . $newName));
exit;
} else {
$message = 'Map bestaat al of ongeldige naam.';
@@ -1802,13 +1888,13 @@ function handleContentDirRename($auth, $config, $twig, $user, $csrf): void
function handleContentDirDelete($auth, $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /admin/content-list');
header('Location: /admin/content');
exit;
}
$user = $auth->getCurrentUser();
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
header('Location: /admin/content-list');
header('Location: /admin/content');
exit;
}
@@ -1826,7 +1912,7 @@ function handleContentDirDelete($auth, $config): void
}
}
header('Location: /admin/content-list?dir=' . urlencode(dirname($dir)));
header('Location: /admin/content?dir=' . urlencode(dirname($dir)));
exit;
}
@@ -1872,7 +1958,7 @@ function handleContentMove($auth, $config, $twig, $user, $csrf): void
if (!file_exists($newPath)) {
rename($fullPath, $newPath);
adminLog($config, 'info', $user['username'] . ' verplaatste ' . $item . ' naar ' . $dest);
header('Location: /admin/content-list?dir=' . urlencode($dest));
header('Location: /admin/content?dir=' . urlencode($dest));
exit;
} else {
$message = 'Bestand of map bestaat al op de bestemming.';
@@ -4168,7 +4254,9 @@ function handleMediaList($auth, $config): void
$urlPrefix = '/themes/' . $theme . '/assets/';
} else {
$baseDir = $config['content_dir'];
$urlPrefix = '/content/';
// Serve content media through the /-media/ gateway; /content/ is
// blocked (403) by public/index.php and lives outside the webroot.
$urlPrefix = '/-media/';
}
$realBase = realpath($baseDir);
@@ -4311,7 +4399,7 @@ function scanContentDir(string $fullPath, string $subdir): array
$files = scandir($fullPath);
foreach ($files as $file) {
if ($file === '.' || $file === '..' || str_starts_with($file, '.')) {
if ($file === '.' || $file === '..') {
continue;
}
@@ -4372,7 +4460,7 @@ function scanEditorFiles(string $dir, string $scope = 'plugin'): array
if ($scope === 'theme') {
$skipPaths['assets/css_compiled'] = true;
}
return scanEditorFilesNode($realBase, '', $realBase, $skipPaths);
return scanEditorFilesNode($realBase, '', $realBase, $skipPaths, $scope);
}
/**
@@ -4382,8 +4470,9 @@ function scanEditorFiles(string $dir, string $scope = 'plugin'): array
* @param string $relPath Relative path of $absDir within the base (empty for root)
* @param string $realBase The real base root (for path-traversal guard)
* @param array $skipPaths Map of relative paths to skip (e.g. ['assets/css_compiled' => true])
* @param string $scope 'plugin' | 'theme' | 'content' — content toont . bestanden/mappen
*/
function scanEditorFilesNode(string $absDir, string $relPath, string $realBase, array $skipPaths = []): array
function scanEditorFilesNode(string $absDir, string $relPath, string $realBase, array $skipPaths = [], string $scope = 'plugin'): array
{
$nodes = [];
if (!is_dir($absDir)) {
@@ -4397,10 +4486,16 @@ function scanEditorFilesNode(string $absDir, string $relPath, string $realBase,
if ($entry === '.' || $entry === '..') {
continue;
}
// Skip hidden / dotfiles (incl. .gitkeep, .mtime, .bak, .git)
// Plugin/theme scope: skip dotfiles (.gitkeep, .mtime, .bak, .git)
// Content scope: toon . bestanden/mappen (bijv. .map), maar sla .git/.gitkeep over
if ($entry !== '' && $entry[0] === '.') {
if ($scope !== 'content') {
continue;
}
if ($entry === '.git' || $entry === '.gitkeep') {
continue;
}
}
$childRelPath = $relPath === '' ? $entry : ($relPath . '/' . $entry);
// Skip scope-specific paths (e.g. theme css_compiled runtime artefact)
@@ -4428,7 +4523,7 @@ function scanEditorFilesNode(string $absDir, string $relPath, string $realBase,
];
if ($isDir) {
$node['children'] = scanEditorFilesNode($realChild, $childRelPath, $realBase, $skipPaths);
$node['children'] = scanEditorFilesNode($realChild, $childRelPath, $realBase, $skipPaths, $scope);
}
$nodes[] = $node;
@@ -4500,7 +4595,7 @@ function collectContentPages(string $contentDir): array
// Skip hidden / dash-prefixed segments (private assets etc.)
$skip = false;
foreach (explode('/', $relative) as $segment) {
if ($segment !== '' && ($segment[0] === '.' || $segment[0] === '-')) {
if ($segment !== '' && $segment[0] === '-') {
$skip = true;
break;
}
+2 -2
View File
@@ -4,8 +4,8 @@
*/
return [
'version' => '2.6.2',
'release_date' => '2026-08-19',
'version' => '2.6.3',
'release_date' => '2026-08-20',
'codename' => 'Lyra',
'status' => 'stable',