Merge pull request #39 from GeoffMoore1979/feature/site-scan-and-editor-workflow

Add site scan, editor-based publishing workflow and subfolder support
This commit is contained in:
Andy Dixon
2026-09-24 14:09:15 +01:00
committed by GitHub
18 changed files with 1706 additions and 526 deletions
+16
View File
@@ -0,0 +1,16 @@
<?php
/** Shared entry-point setup: loads configuration into $config and registers the NeoCMS class autoloader. */
require_once __DIR__ . '/config.php';
// URL prefix of the site root: "/neocms" for http://localhost/neocms/, "" for a site at the domain root.
// Derived from this script's URL unless config.local.php sets 'basePath' (e.g. behind a path-rewriting proxy).
$config['basePath'] = rtrim((string) ($config['basePath'] ?? (preg_match('#^(.*)/cms/#', $_SERVER['SCRIPT_NAME'] ?? '', $neoBase) ? $neoBase[1] : '')), '/');
// The site root on disk is always the folder containing cms/, whatever DOCUMENT_ROOT says.
$config['siteRoot'] ??= dirname(__DIR__);
$config['security']['cookiePath'] = $config['basePath'] . '/cms';
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once __DIR__ . "/src/{$classPath}.php";
});
+1 -7
View File
@@ -7,13 +7,7 @@
*/ */
// Load the site-specific credentials, roles, and content settings. // Load the site-specific credentials, roles, and content settings.
require_once "../config.php"; require_once __DIR__ . '/../bootstrap.php';
// Resolve NeoCMS namespaces to the matching class beneath cms/src/.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "../src/{$classPath}.php";
});
use NeoCMS\CMSController; use NeoCMS\CMSController;
+144 -39
View File
@@ -109,10 +109,6 @@ body {
outline: 0; outline: 0;
} }
.headerButton.dangerButton {
background-color: #6E4D50;
}
.headerButton:hover { .headerButton:hover {
filter: brightness(1.08); filter: brightness(1.08);
} }
@@ -208,26 +204,6 @@ body {
display: none; display: none;
} }
.filelist-content {
padding: 20px;
background-color: #fefefe;
border: 1px solid #888;
width: 400px;
margin: auto;
}
#fileList {
list-style-type: none;
padding: 0;
}
#fileList li {
padding: 10px;
cursor: pointer;
background-color: #f0f0f0;
margin-bottom: 5px;
}
#pageSearch { #pageSearch {
width: 100%; width: 100%;
padding: 9px; padding: 9px;
@@ -235,17 +211,6 @@ body {
box-sizing: border-box; box-sizing: border-box;
} }
#fileList li {
display: flex;
gap: 6px;
align-items: center;
}
#fileList .page-open {
flex: 1;
text-align: left;
}
.cms-dialog label { .cms-dialog label {
display: block; display: block;
margin: 12px 0; margin: 12px 0;
@@ -268,11 +233,47 @@ body {
/* Content-editor actions sit beneath TinyMCE now that no Bootstrap modal is required. */ /* Content-editor actions sit beneath TinyMCE now that no Bootstrap modal is required. */
.editor-actions { .editor-actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
/* Light, regular-weight buttons; Publish Now is the only filled one, in green. */
#editModal .editor-actions button {
padding: 6px 14px;
border: 1px solid #c9cfd8;
border-radius: 4px;
background: #fff;
color: #374151;
font-size: 13px;
font-weight: 400;
cursor: pointer;
}
#editModal .editor-actions button:hover {
background: #f3f5f8;
}
#editModal .editor-actions #saveBtn {
border-color: #9db8d8;
color: #2f5a8a;
}
#editModal .editor-actions #saveBtn:hover {
background: #eaf1fb;
}
#editModal .editor-actions .publish-now {
background: #2e7d4f;
border-color: #2e7d4f;
color: #fff;
}
#editModal .editor-actions .publish-now:hover {
background: #256a42;
}
/* Tool launcher expands to available width without requiring a row of tiny buttons. */ /* Tool launcher expands to available width without requiring a row of tiny buttons. */
.tool-grid { .tool-grid {
display: grid; display: grid;
@@ -342,10 +343,6 @@ body {
color: #a51d2d; color: #a51d2d;
} }
#fileList li:hover {
background-color: #e0e0e0;
}
.redText { .redText {
color: #ff4d4d; color: #ff4d4d;
} }
@@ -403,3 +400,111 @@ body {
margin-top: 4px; margin-top: 4px;
} }
} }
/* Site scan dialogue: selectable page rows and the option group. */
#siteScanOptions { border: 1px solid #ccc; margin: 12px 0; padding: 8px 12px; }
#siteScanOptions label { display: block; margin: 4px 0; }
#siteScanPages { max-height: 40vh; overflow: auto; }
.scan-row { display: block; cursor: pointer; }
.scan-note { color: #555; font-size: 0.9em; }
/* Image dialogue: thumbnail picker and address fields. */
#imagePicker img { cursor: pointer; }
#imageForm label { display: block; margin: 8px 0; }
#imageForm input[type="text"] { display: block; width: 100%; box-sizing: border-box; }
/* Page picker: full-width table with a document icon, draft badge, and per-row actions. */
.filelist-content { padding: 4px 0 0; }
.table-wrap { max-height: 60vh; overflow: auto; border: 1px solid #dfe3ea; border-radius: 8px; }
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.data-table th {
position: sticky; top: 0; z-index: 1; text-align: left; padding: 10px 14px;
background: #f3f5f8; color: #4b5563; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em;
border-bottom: 1px solid #dfe3ea;
}
.data-table td { padding: 10px 14px; border-bottom: 1px solid #edf0f4; vertical-align: middle; }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:nth-child(even) { background: #fafbfc; }
.page-row { cursor: pointer; }
.page-row:hover { background: #eaf1fb !important; }
.page-cell { white-space: nowrap; }
.doc-icon { vertical-align: middle; margin-right: 8px; color: #4a7bb7; flex: none; }
.data-table .page-open { border: 0; background: none; padding: 0; font: inherit; font-weight: 600; color: #1f2937; cursor: pointer; text-align: left; }
.data-table .page-open:hover, .data-table .page-open:focus { color: #2563a8; text-decoration: underline; }
.page-title { color: #4b5563; }
.page-date { color: #6b7280; white-space: nowrap; }
.row-actions { white-space: nowrap; text-align: right; }
.row-actions button, .row-button { margin-left: 4px; padding: 4px 9px; font-size: 12px; cursor: pointer; }
.badge-draft {
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
background: #fff3d6; color: #8a5a00; border: 1px solid #f0d58a;
}
@media (max-width: 700px) { #fileList .page-title, #fileList th:nth-child(2), #fileList .page-date, #fileList th:nth-child(3) { display: none; } }
/* Site scan progress bar and file count. */
#siteScanProgress { margin: 0 0 12px; }
#scanProgressBar { width: 100%; height: 16px; accent-color: #4a7bb7; }
#scanProgressText { margin-top: 4px; font-size: 13px; color: #4b5563; }
/* Shared polish for dialogue content: stat tiles, section headings, empty states, badges. */
.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 14px; }
.stat { padding: 10px 14px; background: #f3f5f8; border: 1px solid #dfe3ea; border-radius: 8px; font-size: 13px; color: #4b5563; }
.stat strong { font-size: 18px; color: #1f2937; margin-right: 2px; }
.dialog-section { margin: 18px 0 8px; font-size: 14px; color: #374151; }
.empty-state { padding: 18px; text-align: center; color: #6b7280; background: #fafbfc; border: 1px dashed #dfe3ea; border-radius: 8px; }
.page-sub { display: block; margin: 2px 0 0 26px; font-size: 12px; font-weight: 400; color: #6b7280; white-space: normal; }
.check-cell { width: 34px; text-align: center; }
.data-table .page-cell strong { font-weight: 600; color: #1f2937; }
.badge-pending, .badge-failed { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.badge-pending { background: #e7f0fb; color: #1e5a99; border: 1px solid #bcd3ee; }
.badge-failed { background: #fdeaec; color: #a51d2d; border: 1px solid #f0b8bf; }
.issue { border-radius: 6px; }
/* New-page templates as selectable rows. */
#radioList { display: grid; gap: 8px; margin-bottom: 14px; }
.template-option { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #dfe3ea; border-radius: 8px; cursor: pointer; }
.template-option:hover { background: #f3f7fc; }
.template-option:has(input:checked) { border-color: #4a7bb7; background: #eaf1fb; }
.template-option .doc-icon { margin: 0; }
/* Media library cards. */
.media-card { border-radius: 10px; border-color: #dfe3ea; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); transition: box-shadow 0.15s; }
.media-card:hover { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); }
.media-card img { border-radius: 6px; }
.media-card strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.media-card small { color: #6b7280; }
/* Snackbar: bottom-centre toast for success and error messages. */
#message-bar {
position: fixed; left: 50%; bottom: 28px; z-index: 100000;
display: flex; align-items: center; gap: 12px;
min-width: 280px; max-width: min(560px, calc(100vw - 32px)); box-sizing: border-box;
padding: 12px 10px 12px 14px; background: #fff; color: #1f2937;
border-left: 5px solid #18733c; border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
opacity: 0; transform: translate(-50%, 24px); pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
#message-bar[hidden] { display: none; }
#message-bar.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
#message-bar.is-error { border-left-color: #a51d2d; }
.snack-icon { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #18733c; color: #fff; font-size: 14px; font-weight: 700; }
#message-bar.is-error .snack-icon { background: #a51d2d; }
#message-bar.is-success .snack-icon::before { content: "\2713"; }
#message-bar.is-error .snack-icon::before { content: "!"; }
.snack-text { flex: 1; word-break: break-word; }
.snack-close { flex: none; border: 0; background: none; padding: 0 6px; font-size: 22px; line-height: 1; color: #6b7280; cursor: pointer; }
.snack-close:hover { color: #111827; }
@media (prefers-reduced-motion: reduce) { #message-bar { transition: none; } }
/* New-page dialogue: fill the dialogue instead of sitting in a bordered box inside it. */
.newpage-content { padding: 0; background: none; border: 0; width: auto; }
#newPageForm > label[for="filename"] { margin: 4px 0 6px; font-weight: 600; }
#newPageForm input[type="text"] { width: 100%; box-sizing: border-box; padding: 9px; margin-bottom: 16px; border: 1px solid #c9cfd8; border-radius: 6px; }
#newPageForm button[type="submit"] { align-self: flex-end; padding: 9px 22px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
#newPageForm button[type="submit"]:hover { background: #2f5a8a; }
/* Primary submit buttons inside dialogue forms. */
.cms-dialog form button[type="submit"] { padding: 9px 18px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
.cms-dialog form button[type="submit"]:hover { background: #2f5a8a; }
#sharedList, #menuList { margin-bottom: 16px; }
+11 -8
View File
@@ -7,16 +7,12 @@
*/ */
// Load credentials, role assignments, and shared application settings. // Load credentials, role assignments, and shared application settings.
require_once "config.php"; require_once __DIR__ . '/bootstrap.php';
// Resolve project classes without requiring Composer for this deliberately lightweight CMS.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "./src/{$classPath}.php";
});
use NeoCMS\Activity;
use NeoCMS\Authentication; use NeoCMS\Authentication;
use NeoCMS\FileStore; use NeoCMS\FileStore;
use NeoCMS\Logger;
use NeoCMS\SecurityHeaders; use NeoCMS\SecurityHeaders;
SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null); SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
@@ -164,7 +160,7 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
chmod($target_file, 0644); chmod($target_file, 0644);
// Seed media metadata from the original basename; editors can improve the alt text later. // Seed media metadata from the original basename; editors can improve the alt text later.
$location = '/uploads/' . $filename; $location = $config['basePath'] . '/uploads/' . $filename;
$metadata = $store->read('media'); $metadata = $store->read('media');
$originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME); $originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME);
$altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200); $altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200);
@@ -181,6 +177,13 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
} }
flock($uploadLock, LOCK_UN); flock($uploadLock, LOCK_UN);
fclose($uploadLock); fclose($uploadLock);
// The file is saved by now, so a failure to write the audit entry must not fail the upload.
try {
(new Activity($store, new Logger($config['audit'] ?? true, $config['security'] ?? [])))
->record($authentication->getLoggedInUser(), 'Uploaded image', $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
} catch (\Throwable $exception) {
// Deliberately ignored.
}
echo json_encode(['location' => $location]); echo json_encode(['location' => $location]);
} else { } else {
flock($uploadLock, LOCK_UN); flock($uploadLock, LOCK_UN);
+60 -24
View File
@@ -2,13 +2,7 @@
/** Server-rendered shell for the authenticated NeoCMS administration application. */ /** Server-rendered shell for the authenticated NeoCMS administration application. */
// Load configuration before initialising sessions and role-aware controls. // Load configuration before initialising sessions and role-aware controls.
require_once "config.php"; require_once __DIR__ . '/bootstrap.php';
// Resolve project classes without requiring Composer.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "./src/{$classPath}.php";
});
use NeoCMS\Authentication; use NeoCMS\Authentication;
use NeoCMS\SecurityHeaders; use NeoCMS\SecurityHeaders;
@@ -24,7 +18,7 @@ if (!is_string($editableClass) || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $ed
// Keep the administration shell private by redirecting anonymous requests to login. // Keep the administration shell private by redirecting anonymous requests to login.
if (!$authentication->isLoggedIn()) { if (!$authentication->isLoggedIn()) {
header("Location: /cms/login/"); header("Location: " . $config['basePath'] . "/cms/login/");
exit; exit;
} }
SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null); SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
@@ -35,16 +29,21 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<head> <head>
<title>NeoCMS</title> <title>NeoCMS</title>
<meta name="csrf-token" content="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>"> <meta name="csrf-token" content="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">
<meta name="neo-base-path" content="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>">
<meta name="neo-editable-class" content="<?php echo htmlspecialchars($editableClass, ENT_QUOTES, 'UTF-8'); ?>"> <meta name="neo-editable-class" content="<?php echo htmlspecialchars($editableClass, ENT_QUOTES, 'UTF-8'); ?>">
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha384-fgGyf7Mo7DURSOMnOy7ed+dkq5Job205Gnzu6QIg0BOHKaqt4D76Dt8VlDCzcMHV" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha384-fgGyf7Mo7DURSOMnOy7ed+dkq5Job205Gnzu6QIg0BOHKaqt4D76Dt8VlDCzcMHV" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/ui/1.14.2/jquery-ui.min.js" integrity="sha384-tBcEcHGtNy7/Mx08+YxuvQ6v6s0N2jgehtFiT+bLtGwTj/txXtB/L5GqXfggm5sS" crossorigin="anonymous"></script> <script src="https://code.jquery.com/ui/1.14.2/jquery-ui.min.js" integrity="sha384-tBcEcHGtNy7/Mx08+YxuvQ6v6s0N2jgehtFiT+bLtGwTj/txXtB/L5GqXfggm5sS" crossorigin="anonymous"></script>
<script src="/cms/tinymce/tinymce.min.js"></script> <script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/tinymce/tinymce.min.js"></script>
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.2/themes/base/jquery-ui.css" integrity="sha384-pUvA/6DQjteMxpaV6uGxZ1QuYrFLJgrLMvBWf06VcJIg6ky/Y5m3UZJlrv11V1I+" crossorigin="anonymous"> <link rel="stylesheet" href="https://code.jquery.com/ui/1.14.2/themes/base/jquery-ui.css" integrity="sha384-pUvA/6DQjteMxpaV6uGxZ1QuYrFLJgrLMvBWf06VcJIg6ky/Y5m3UZJlrv11V1I+" crossorigin="anonymous">
<link rel="stylesheet" href="/cms/css/editor.css"> <link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/editor.css">
</head> </head>
<body data-role="<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>"> <body data-role="<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>">
<!-- Status messages sit outside the flex layout so they may overlay the full viewport. --> <!-- Status messages sit outside the flex layout so they may overlay the full viewport. -->
<div id="message-bar" style="display:none;"></div> <div id="message-bar" role="status" aria-live="polite" hidden>
<span class="snack-icon" aria-hidden="true"></span>
<span class="snack-text"></span>
<button type="button" class="snack-close" aria-label="Dismiss message">&times;</button>
</div>
<div class="pageContainer"> <div class="pageContainer">
<div class="controls"> <div class="controls">
<div class="logo"></div> <div class="logo"></div>
@@ -62,9 +61,6 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<button class="headerButton" id="moreButton">Tools</button> <button class="headerButton" id="moreButton">Tools</button>
</div> </div>
<div class="toolbar-row publish-controls"> <div class="toolbar-row publish-controls">
<button class="headerButton" id="saveDraft">Save Draft</button>
<button class="headerButton publish-only" id="scheduleButton">Schedule</button>
<button class="headerButton dangerButton publish-only" id="savePage">Publish</button>
<button class="headerButton viewport-button" data-width="100%">Desktop</button> <button class="headerButton viewport-button" data-width="100%">Desktop</button>
<button class="headerButton viewport-button" data-width="768px">Tablet</button> <button class="headerButton viewport-button" data-width="768px">Tablet</button>
<button class="headerButton viewport-button" data-width="390px">Mobile</button> <button class="headerButton viewport-button" data-width="390px">Mobile</button>
@@ -81,15 +77,17 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
?> ?>
</div> </div>
</div> </div>
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? "/" : "welcome.html"; ?>" <iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8') . "/" : "welcome.html"; ?>"
class="frame" sandbox="allow-same-origin"></iframe> class="frame" sandbox="allow-same-origin"></iframe>
</div> </div>
<!-- TinyMCE content editor. Saving here changes the preview, not the public page. --> <!-- TinyMCE content editor. Save Draft keeps changes private; Schedule Publish and Publish Now act on the whole page. -->
<div id="editModal" class="cms-dialog" title="Edit Content"> <div id="editModal" class="cms-dialog" title="Edit Content">
<textarea id="editor"></textarea> <textarea id="editor"></textarea>
<div class="editor-actions"> <div class="editor-actions">
<button id="saveBtn" type="button">Save</button> <button id="saveBtn" type="button" title="Applies your changes and saves them as a private draft">Save Draft</button>
<button id="closeEditorBtn" type="button">Close</button> <button id="scheduleBtn" type="button" class="publish-only" title="Publishes this page automatically at a time you choose">Schedule Publish</button>
<button id="publishBtn" type="button" class="publish-only publish-now" title="Replaces the live page now; a revision of the old page is kept">Publish Now</button>
<button id="closeEditorBtn" type="button">Cancel</button>
</div> </div>
</div> </div>
@@ -97,6 +95,7 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<div id="newPageDialog" title="Create a New Page"> <div id="newPageDialog" title="Create a New Page">
<div class="newpage-content"> <div class="newpage-content">
<form id="newPageForm"> <form id="newPageForm">
<h3 class="dialog-section">Choose a template</h3>
<div id="radioList"> <div id="radioList">
<!-- JavaScript populates available filesystem templates here. --> <!-- JavaScript populates available filesystem templates here. -->
</div> </div>
@@ -111,10 +110,18 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<!-- Searchable page picker with role-dependent management actions. --> <!-- Searchable page picker with role-dependent management actions. -->
<div id="fileListDialog" title="Select an Existing Page"> <div id="fileListDialog" title="Select an Existing Page">
<div class="filelist-content"> <div class="filelist-content">
<input id="pageSearch" type="search" placeholder="Search pages"> <input id="pageSearch" type="search" placeholder="Search pages" aria-label="Search pages">
<ul id="fileList"> <div class="table-wrap">
<table id="fileList" class="data-table">
<thead>
<tr><th>Page</th><th>Title</th><th>Last modified</th><th class="manage-col">Actions</th></tr>
</thead>
<tbody>
<!-- JavaScript populates discovered editable pages here. --> <!-- JavaScript populates discovered editable pages here. -->
</ul> </tbody>
</table>
<p id="pageListEmpty" class="scan-note" hidden>No pages match your search.</p>
</div>
</div> </div>
</div> </div>
@@ -128,6 +135,7 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<button id="accessibilityButton">Accessibility check</button> <button id="accessibilityButton">Accessibility check</button>
<button id="sharedButton" class="manage-only">Shared content</button> <button id="sharedButton" class="manage-only">Shared content</button>
<button id="menusButton" class="manage-only">Navigation menus</button> <button id="menusButton" class="manage-only">Navigation menus</button>
<button id="siteScanButton" class="manage-only">Site scan</button>
</div> </div>
</div> </div>
@@ -165,25 +173,53 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<!-- Site-wide shared-region registry and propagation form. --> <!-- Site-wide shared-region registry and propagation form. -->
<div id="sharedDialog" class="cms-dialog" title="Shared Content"> <div id="sharedDialog" class="cms-dialog" title="Shared Content">
<div id="sharedList"></div>
<form id="sharedForm"> <form id="sharedForm">
<label>Block name<input id="sharedKey" type="text" pattern="[A-Za-z0-9_-]+" required></label> <label>Block name<input id="sharedKey" type="text" pattern="[A-Za-z0-9_-]+" required></label>
<label>HTML content<textarea id="sharedContent" rows="8" required></textarea></label> <label>HTML content<textarea id="sharedContent" rows="8" required></textarea></label>
<button type="submit">Save and update every page</button> <button type="submit">Save and update every page</button>
</form> </form>
<div id="sharedList"></div>
</div> </div>
<!-- Line-oriented menu editor supporting an optional parent label for nesting. --> <!-- Line-oriented menu editor supporting an optional parent label for nesting. -->
<div id="menusDialog" class="cms-dialog" title="Navigation Menus"> <div id="menusDialog" class="cms-dialog" title="Navigation Menus">
<div id="menuList"></div>
<form id="menuForm"> <form id="menuForm">
<label>Menu name<input id="menuName" type="text" pattern="[A-Za-z0-9_-]+" required></label> <label>Menu name<input id="menuName" type="text" pattern="[A-Za-z0-9_-]+" required></label>
<label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | /&#10;About | /about.html&#10;Team | /team.html | About" required></textarea></label> <label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | /&#10;About | /about.html&#10;Team | /team.html | About" required></textarea></label>
<button type="submit">Save menu</button> <button type="submit">Save menu</button>
</form> </form>
<div id="menuList"></div>
</div> </div>
<script src="/cms/js/cms.js"></script> <!-- Whole-site analysis with one-click tagging of content regions, images, and SEO tags. Administrators only. -->
<div id="siteScanDialog" class="cms-dialog" title="Site Scan">
<div id="siteScanProgress" hidden>
<progress id="scanProgressBar" max="100" value="0"></progress>
<div id="scanProgressText" role="status" aria-live="polite"></div>
</div>
<div id="siteScanSummary"></div>
<fieldset id="siteScanOptions">
<legend>Changes to make</legend>
<label><input type="checkbox" id="scanContent" checked> Make content areas editable</label>
<label><input type="checkbox" id="scanImages" checked> Make images editable</label>
<label><input type="checkbox" id="scanSeo" checked> Add missing SEO tags (never overwrites existing ones)</label>
</fieldset>
<div id="siteScanPages"></div>
<p class="scan-note">Each changed page keeps a revision, so this can be undone from Revision history.</p>
<button id="siteScanApply" type="button">Apply to selected pages</button>
</div>
<!-- Replace or describe an image that sits outside an editable region. -->
<div id="imageDialog" class="cms-dialog" title="Edit Image">
<div id="imagePicker" class="media-grid"></div>
<form id="imageForm">
<label>Image address<input id="imageSrc" type="text" required></label>
<label>Alternative text<input id="imageAlt" type="text"></label>
<button type="submit">Apply image</button>
</form>
</div>
<script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/js/cms.js"></script>
</body> </body>
</html> </html>
+475 -65
View File
@@ -11,6 +11,7 @@
// References to the active iframe document and currently selected editable element. // References to the active iframe document and currently selected editable element.
let iframeDoc = null; let iframeDoc = null;
let currentElement = null; let currentElement = null;
let currentImage = null;
// Dirty state protects unpublished browser changes from accidental navigation. // Dirty state protects unpublished browser changes from accidental navigation.
let dirty = false; let dirty = false;
@@ -21,10 +22,27 @@
// Remember offered drafts so an iframe rewrite does not prompt for the same draft twice. // Remember offered drafts so an iframe rewrite does not prompt for the same draft twice.
const loadedDrafts = new Set(); const loadedDrafts = new Set();
// True once the page shown has been saved as a server-side draft (cleared by any navigation).
let draftSaved = false;
// Path of the page shown in the preview. Writing a draft into the frame (document.open) makes the frame's own URL
// become the admin page's, so the real page path is captured when the page loads instead.
let framePath = '/';
// Set while a saved draft is written into the preview, so its load event is not treated as navigation.
let writingDraft = false;
// URL of a preview page that was reloaded to bypass the browser cache; its next load is the fresh one.
let revalidatedHref = null;
// Server-rendered values keep selectors and mutating requests aligned with configuration. // Server-rendered values keep selectors and mutating requests aligned with configuration.
const csrfToken = $('meta[name="csrf-token"]').attr('content') || ''; const csrfToken = $('meta[name="csrf-token"]').attr('content') || '';
const editableClass = $('meta[name="neo-editable-class"]').attr('content') || 'editable'; const editableClass = $('meta[name="neo-editable-class"]').attr('content') || 'editable';
const editableSelector = '.' + editableClass; const editableSelector = '.' + editableClass;
// A styled box standing in for a photo: role="img" on something other than an <img> or <svg>.
const placeholderSelector = '[role="img"]:not(img):not(svg):not([aria-hidden="true"])';
// URL prefix of the site root (empty at a domain root, "/neocms" for http://localhost/neocms/).
const basePath = $('meta[name="neo-base-path"]').attr('content') || '';
// Initialise controls after the administration document is ready. // Initialise controls after the administration document is ready.
$(function () { $(function () {
@@ -51,7 +69,7 @@
if ((method || 'GET') === 'POST') { if ((method || 'GET') === 'POST') {
payload.csrf_token = csrfToken; payload.csrf_token = csrfToken;
} }
return $.ajax({url: '/cms/controller/', method: method || 'GET', data: payload, dataType: 'json'}) return $.ajax({url: basePath + '/cms/controller/', method: method || 'GET', data: payload, dataType: 'json'})
.catch(function (xhr) { .catch(function (xhr) {
const response = xhr.responseJSON || {}; const response = xhr.responseJSON || {};
throw new Error(response.error || 'The CMS request failed'); throw new Error(response.error || 'The CMS request failed');
@@ -64,6 +82,8 @@
$('.cms-dialog, #newPageDialog, #fileListDialog').not('#editModal').each(function () { $('.cms-dialog, #newPageDialog, #fileListDialog').not('#editModal').each(function () {
$(this).dialog({autoOpen: false, modal: true, width: Math.min(760, window.innerWidth - 30)}); $(this).dialog({autoOpen: false, modal: true, width: Math.min(760, window.innerWidth - 30)});
}); });
// The page list is a wide table, so it gets a wider dialogue than the other tools.
$('#fileListDialog').dialog('option', 'width', Math.min(1000, window.innerWidth - 30));
$('#editModal').dialog({ $('#editModal').dialog({
autoOpen: false, autoOpen: false,
modal: true, modal: true,
@@ -78,9 +98,6 @@
$('#dashboardButton').on('click', function () { loadDashboard(true); }); $('#dashboardButton').on('click', function () { loadDashboard(true); });
$('#newPage').on('click', openNewPage); $('#newPage').on('click', openNewPage);
$('#selectPage').on('click', openPages); $('#selectPage').on('click', openPages);
$('#saveDraft').on('click', saveDraft);
$('#savePage').on('click', publishPage);
$('#scheduleButton').on('click', openSchedule);
$('#mediaButton').on('click', openMedia); $('#mediaButton').on('click', openMedia);
$('#seoButton').on('click', openSeo); $('#seoButton').on('click', openSeo);
$('#moreButton').on('click', function () { $('#toolsDialog').dialog('open'); }); $('#moreButton').on('click', function () { $('#toolsDialog').dialog('open'); });
@@ -88,6 +105,9 @@
$('#accessibilityButton').on('click', runAccessibilityCheck); $('#accessibilityButton').on('click', runAccessibilityCheck);
$('#sharedButton').on('click', openShared); $('#sharedButton').on('click', openShared);
$('#menusButton').on('click', openMenus); $('#menusButton').on('click', openMenus);
$('#siteScanButton').on('click', openSiteScan);
$('#siteScanApply').on('click', applySiteScan);
$('#imageForm').on('submit', applyImage);
$('#seoForm').on('submit', applySeo); $('#seoForm').on('submit', applySeo);
$('#scheduleForm').on('submit', schedulePage); $('#scheduleForm').on('submit', schedulePage);
$('#sharedForm').on('submit', saveShared); $('#sharedForm').on('submit', saveShared);
@@ -107,6 +127,22 @@
*/ */
function initialiseFrame() { function initialiseFrame() {
const iframe = document.getElementById('frameContainer'); const iframe = document.getElementById('frameContainer');
// Static pages carry no cache headers, so a browser may reuse a stale copy (for example after the whole site was
// replaced). Reload each newly opened page once, which revalidates it; skip when edits would be discarded.
const href = iframe.contentWindow.location.href;
// The load caused by writing a saved draft into the frame is not a navigation: keep the draft's content and state.
const draftWrite = writingDraft;
writingDraft = false;
if (!dirty && !draftWrite && href !== 'about:blank' && revalidatedHref !== href) {
revalidatedHref = href;
iframe.contentWindow.location.reload();
return;
}
revalidatedHref = null;
if (!draftWrite) {
draftSaved = false;
framePath = iframe.contentWindow.location.pathname;
}
iframeDoc = iframe.contentDocument || iframe.contentWindow.document; iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
currentElement = null; currentElement = null;
@@ -116,7 +152,12 @@
if ($(event.target).closest('.button-container').length) return; if ($(event.target).closest('.button-container').length) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openEditor($(this)); // Clicking an image inside a region edits just that image; the rest of the region opens the content editor.
const image = $(event.target).closest('img');
const placeholder = image.length ? $() : imagePlaceholder(event.target);
if (image.length) openImageEditor(image);
else if (placeholder.length) openImageEditor(placeholder);
else openEditor($(this));
}); });
// Internal page navigation receives the same unpublished-change protection as the window. // Internal page navigation receives the same unpublished-change protection as the window.
$(iframeDoc).on('click.neocms', 'a', function (event) { $(iframeDoc).on('click.neocms', 'a', function (event) {
@@ -126,11 +167,29 @@
}); });
$(iframeDoc).on('click.neocms', '.duplicate-before, .duplicate-after', duplicateBlock); $(iframeDoc).on('click.neocms', '.duplicate-before, .duplicate-after', duplicateBlock);
$(iframeDoc).on('click.neocms', '.delete-block', deleteBlock); $(iframeDoc).on('click.neocms', '.delete-block', deleteBlock);
// Images marked by the site scan open the image dialogue, unless an editable region owns them.
// Editor-only cue that images are clickable; a removable <style> keeps it out of saved pages.
$('<style id="neo-editor-style">').text(
'img[data-neo-image],' + placeholderSelector + '[data-neo-image]{cursor:pointer;outline:2px dashed #4a90d9;outline-offset:2px}'
+ editableSelector + ' img,' + editableSelector + ' ' + placeholderSelector + '{cursor:pointer}'
+ editableSelector + ' img:hover,' + editableSelector + ' ' + placeholderSelector + ':hover{outline:2px dashed #4a90d9;outline-offset:2px}'
).appendTo(iframeDoc.head || iframeDoc.documentElement);
$(iframeDoc).on('click.neocms', 'img[data-neo-image], ' + placeholderSelector + '[data-neo-image]', function (event) {
if ($(this).closest(editableSelector).length) return;
event.preventDefault();
event.stopPropagation();
openImageEditor($(this));
});
addBlockControls(); addBlockControls();
updateUrl(); updateUrl();
offerDraft(); offerDraft();
} }
/** The photo-placeholder box containing a click target, when it has no real image yet. */
function imagePlaceholder(target) {
return $(target).closest(placeholderSelector).filter(function () { return !$(this).find('img').length; });
}
/** Open TinyMCE with the selected region's inner HTML. */ /** Open TinyMCE with the selected region's inner HTML. */
function openEditor(element) { function openEditor(element) {
currentElement = element; currentElement = element;
@@ -167,7 +226,7 @@
form.append('file', blobInfo.blob(), blobInfo.filename()); form.append('file', blobInfo.blob(), blobInfo.filename());
form.append('csrf_token', csrfToken); form.append('csrf_token', csrfToken);
$.ajax({ $.ajax({
url: '/cms/image_upload.php', method: 'POST', data: form, processData: false, contentType: false, url: basePath + '/cms/image_upload.php', method: 'POST', data: form, processData: false, contentType: false,
xhr: function () { xhr: function () {
const xhr = $.ajaxSettings.xhr(); const xhr = $.ajaxSettings.xhr();
xhr.upload.addEventListener('progress', function (event) { xhr.upload.addEventListener('progress', function (event) {
@@ -180,16 +239,34 @@
}); });
} }
// Apply modal edits to the iframe only; the page remains unpublished until explicitly saved. // Save applies the edit to the preview and keeps it as a private draft; the public page changes only on Publish.
$('#saveBtn').on('click', function () { $('#saveBtn').on('click', function () {
const editor = tinymce.get('editor'); const changed = applyEditorContent();
if (editor && currentElement) {
currentElement.html(editor.getContent());
markDirty();
}
$('#editModal').dialog('close'); $('#editModal').dialog('close');
if (changed) commitEdit();
}); });
// Publish Now and Schedule Publish act on the whole page, so the editor content is applied to the preview first.
// The editor stays open until the action is confirmed, so a cancelled confirm or dialogue loses nothing.
$('#publishBtn').on('click', async function () {
if (!applyEditorContent()) return;
markDirty();
if (await publishPage()) $('#editModal').dialog('close');
});
$('#scheduleBtn').on('click', function () {
if (!applyEditorContent()) return;
markDirty();
openSchedule();
});
/** Copy the content editor's HTML into the selected region of the preview; false when there is nothing to apply. */
function applyEditorContent() {
const editor = tinymce.get('editor');
if (!(editor && currentElement)) return false;
currentElement.html(editor.getContent());
return true;
}
/** Clone a repeatable block before or after its source, then open the clone for editing. */ /** Clone a repeatable block before or after its source, then open the clone for editing. */
function duplicateBlock(event) { function duplicateBlock(event) {
event.preventDefault(); event.preventDefault();
@@ -209,7 +286,7 @@
event.stopPropagation(); event.stopPropagation();
if (window.confirm('Delete this content block?')) { if (window.confirm('Delete this content block?')) {
$(this).closest('.neo-dupe').remove(); $(this).closest('.neo-dupe').remove();
markDirty(); commitEdit();
} }
} }
@@ -237,37 +314,67 @@
$(clone).find('.button-container').remove(); $(clone).find('.button-container').remove();
$(clone).find('[data-neo-original-position="static"]').css('position', '').removeAttr('data-neo-original-position'); $(clone).find('[data-neo-original-position="static"]').css('position', '').removeAttr('data-neo-original-position');
$(clone).find(editableSelector + ', .neo-dupe').css('cursor', ''); $(clone).find(editableSelector + ', .neo-dupe').css('cursor', '');
$(clone).find('#neo-editor-style, base[data-neo-base]').remove();
return '<!DOCTYPE html>\n' + clone.documentElement.outerHTML; return '<!DOCTYPE html>\n' + clone.documentElement.outerHTML;
} }
/** Return the public path of the page currently displayed in the iframe. */ /** Return the public path of the page currently displayed in the iframe. */
function currentUri() { function currentUri() {
return iframeDoc ? iframeDoc.location.pathname : '/'; const path = framePath;
// Server-side URIs are relative to the site root, so drop the subfolder prefix.
return basePath && (path === basePath || path.startsWith(basePath + '/')) ? path.slice(basePath.length) || '/' : path;
} }
/** Save the in-memory page as a private server-side draft. */ /** Save the in-memory page as a private server-side draft; the public page is untouched. */
async function saveDraft() { async function saveDraft(message) {
try { try {
const result = await api('saveDraft', {uri: currentUri(), content: serialisePage()}, 'POST'); await api('saveDraft', {uri: currentUri(), content: serialisePage()}, 'POST');
dirty = false; dirty = false;
showMessage(result.message, 'success'); draftSaved = true;
// A draft now exists for this page, so leaving and returning must offer it again.
loadedDrafts.delete(currentUri());
updateUrl();
showMessage(message || 'Saved as a draft.' + (permissions.publish ? ' Publish to make it live.' : ''), 'success');
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
} }
/** An edit the author has confirmed (dialogue Save or Apply): mark the page changed and keep it as a draft. */
async function commitEdit(message) {
markDirty();
await saveDraft(message);
}
/** Run the pre-publish accessibility prompt and publish the complete page. */ /** Run the pre-publish accessibility prompt and publish the complete page. */
async function publishPage() { async function publishPage() {
if (!permissions.publish) return; if (!permissions.publish) return false;
try { try {
const issues = accessibilityIssues(); const issues = accessibilityIssues();
if (issues.length && !window.confirm('The accessibility check found ' + issues.length + ' issue(s). Publish anyway?')) return; if (issues.length && !window.confirm('The accessibility check found ' + issues.length + ' issue(s). Publish anyway?')) return false;
const result = await api('save', {uri: currentUri(), content: serialisePage()}, 'POST'); const result = await api('save', {uri: currentUri(), content: serialisePage()}, 'POST');
dirty = false; dirty = false;
draftSaved = false;
updateUrl();
showMessage(result.message, 'success'); showMessage(result.message, 'success');
addBlockControls(); addBlockControls();
} catch (error) { showMessage(error.message, 'error'); } return true;
} catch (error) {
showMessage(error.message, 'error');
return false;
}
} }
/** Offer to replace the public preview with its most recent saved draft. */ /** Offer to replace the public preview with its most recent saved draft. */
/**
* Give a draft the address of the page it belongs to. Writing HTML into the frame makes the frame's URL the admin
* page's, so without a base the draft's relative stylesheets, images, and links would resolve inside /cms/ and the
* page would render unstyled. The helper is marked so serialisePage() removes it again.
*/
function withBase(html, pageUrl) {
if (/<base[\s>]/i.test(html)) return html;
const tag = '<base data-neo-base href="' + pageUrl.replace(/&/g, '&amp;').replace(/"/g, '&quot;') + '">';
return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, function (head) { return head + tag; }) : tag + html;
}
async function offerDraft() { async function offerDraft() {
const uri = currentUri(); const uri = currentUri();
if (loadedDrafts.has(uri) || uri.startsWith('/cms/')) return; if (loadedDrafts.has(uri) || uri.startsWith('/cms/')) return;
@@ -275,8 +382,13 @@
try { try {
const draft = await api('getDraft', {uri: uri}); const draft = await api('getDraft', {uri: uri});
if (draft.exists && window.confirm('A saved draft exists for this page. Load it?')) { if (draft.exists && window.confirm('A saved draft exists for this page. Load it?')) {
iframeDoc.open(); iframeDoc.write(draft.content); iframeDoc.close(); writingDraft = true;
dirty = true; const pageUrl = iframeDoc.location.href;
iframeDoc.open(); iframeDoc.write(withBase(draft.content, pageUrl)); iframeDoc.close();
// The frame now matches the saved draft, so there is nothing unsaved.
dirty = false;
draftSaved = true;
updateUrl();
} }
} catch (error) { } catch (error) {
// A page without an available draft needs no noisy notification. // A page without an available draft needs no noisy notification.
@@ -297,6 +409,7 @@
const result = await api('schedule', {uri: currentUri(), content: serialisePage(), publish_at: new Date($('#publishAt').val()).toISOString()}, 'POST'); const result = await api('schedule', {uri: currentUri(), content: serialisePage(), publish_at: new Date($('#publishAt').val()).toISOString()}, 'POST');
dirty = false; dirty = false;
$('#scheduleDialog').dialog('close'); $('#scheduleDialog').dialog('close');
if ($('#editModal').dialog('isOpen')) $('#editModal').dialog('close');
showMessage(result.message, 'success'); showMessage(result.message, 'success');
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
} }
@@ -305,32 +418,80 @@
async function openPages() { async function openPages() {
try { try {
const pages = await api('getPages'); const pages = await api('getPages');
const list = $('#fileList').empty(); const body = $('#fileList tbody').empty();
$('#fileList .manage-col').toggle(permissions.manage);
$('#pageSearch').val('');
$('#pageListEmpty').prop('hidden', true);
pages.forEach(function (page) { pages.forEach(function (page) {
const row = $('<li>').attr('data-search', (page.name + ' ' + page.title).toLowerCase()); const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { navigateTo(page.url); });
$('<button class="page-open">').text(page.title + ' - ' + page.name + (page.draft ? ' [draft]' : '')).on('click', function () { navigateTo(page.url); }).appendTo(row); const name = $('<td class="page-cell">').append(docIcon());
$('<button type="button" class="page-open">').text(page.name).appendTo(name);
if (page.draft) $('<span class="badge-draft">').text('Draft').appendTo(name);
row.append(name, $('<td class="page-title">').text(page.title), $('<td class="page-date">').text(formatDate(page.modified)));
const actions = $('<td class="row-actions manage-col">').toggle(permissions.manage).appendTo(row);
if (permissions.manage) { if (permissions.manage) {
$('<button>').text('Duplicate').on('click', function () { managePage('duplicate', page.url); }).appendTo(row); [['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
$('<button>').text('Rename').on('click', function () { managePage('rename', page.url); }).appendTo(row); $('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
$('<button class="danger-text">').text('Delete').on('click', function () { managePage('delete', page.url); }).appendTo(row); .on('click', function (event) { event.stopPropagation(); managePage(action[1], page.url); }).appendTo(actions);
});
} }
list.append(row); body.append(row);
}); });
$('#fileListDialog').dialog('open'); $('#fileListDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
} }
/** Small line icon; paths are SVG path markup drawn on a 24px grid. */
function svgIcon(paths) {
return $('<svg class="doc-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + '</svg>');
}
/** Document icon shown beside each page. */
function docIcon() {
return svgIcon('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/>');
}
/** Clock icon shown beside revision times. */
function clockIcon() {
return svgIcon('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>');
}
/**
* Append an empty table to a container and return its body.
* Headings may be text or ready-made elements (for example a select-all checkbox).
*/
function dataTable(container, headings) {
const head = $('<tr>');
headings.forEach(function (heading) { $('<th>').append(typeof heading === 'string' ? document.createTextNode(heading) : heading).appendTo(head); });
const body = $('<tbody>');
$('<div class="table-wrap">').append($('<table class="data-table">').append($('<thead>').append(head), body)).appendTo(container);
return body;
}
/** A small button for a table row's action column; clicks do not also trigger the row. */
function rowButton(label, handler, danger) {
return $('<button type="button" class="row-button">').text(label).toggleClass('danger-text', !!danger)
.on('click', function (event) { event.stopPropagation(); handler(); });
}
/** Short local date and time for the page table. */
function formatDate(iso) {
const date = new Date(iso);
return isNaN(date) ? '' : date.toLocaleDateString(undefined, {day: 'numeric', month: 'short', year: 'numeric'}) + ', ' + date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'});
}
/** Hide page-picker rows that do not contain the case-insensitive search text. */ /** Hide page-picker rows that do not contain the case-insensitive search text. */
function filterPages() { function filterPages() {
const query = $(this).val().toLowerCase(); const query = $(this).val().toLowerCase();
$('#fileList li').each(function () { $(this).toggle($(this).data('search').includes(query)); }); $('#fileList tbody tr').each(function () { $(this).toggle($(this).data('search').includes(query)); });
$('#pageListEmpty').prop('hidden', $('#fileList tbody tr:visible').length > 0);
} }
/** Navigate the preview after protecting any unpublished changes. */ /** Navigate the preview after protecting any unpublished changes. */
function navigateTo(url) { function navigateTo(url) {
if (dirty && !window.confirm('Discard unpublished changes?')) return; if (dirty && !window.confirm('Discard unpublished changes?')) return;
dirty = false; dirty = false;
$('#frameContainer').attr('src', url); $('#frameContainer').attr('src', basePath + url);
$('#fileListDialog').dialog('close'); $('#fileListDialog').dialog('close');
} }
@@ -356,7 +517,7 @@
const templates = await api('getTemplates'); const templates = await api('getTemplates');
const list = $('#radioList').empty(); const list = $('#radioList').empty();
templates.forEach(function (item, index) { templates.forEach(function (item, index) {
$('<label>').append($('<input type="radio" name="item">').val(item.id).prop('checked', index === 0), document.createTextNode(' ' + item.name)).appendTo(list); $('<label class="template-option">').append($('<input type="radio" name="item">').val(item.id).prop('checked', index === 0), docIcon(), $('<span>').text(item.name)).appendTo(list);
}); });
$('#newPageDialog').dialog('open'); $('#newPageDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
@@ -378,12 +539,18 @@
try { try {
const revisions = await api('revisions', {uri: currentUri()}); const revisions = await api('revisions', {uri: currentUri()});
const list = $('#revisionsList').empty(); const list = $('#revisionsList').empty();
if (!revisions.length) list.text('No revisions have been recorded for this page.'); if (!revisions.length) {
$('<p class="empty-state">').text('No revisions have been recorded for this page.').appendTo(list);
} else {
const body = dataTable(list, permissions.publish ? ['When', 'User', 'Reason', 'Actions'] : ['When', 'User', 'Reason']);
revisions.forEach(function (revision) { revisions.forEach(function (revision) {
const row = $('<div class="list-row">').text(new Date(revision.created).toLocaleString() + ' - ' + revision.user + ' - ' + revision.reason + ' '); const row = $('<tr>');
if (permissions.publish) $('<button>').text('Restore').on('click', function () { restoreRevision(revision.id); }).appendTo(row); $('<td class="page-cell">').append(clockIcon(), document.createTextNode(formatDate(revision.created))).appendTo(row);
list.append(row); row.append($('<td>').text(revision.user), $('<td class="page-title">').text(revision.reason));
if (permissions.publish) $('<td class="row-actions">').append(rowButton('Restore', function () { restoreRevision(revision.id); })).appendTo(row);
body.append(row);
}); });
}
$('#toolsDialog').dialog('close'); $('#toolsDialog').dialog('close');
$('#revisionsDialog').dialog('open'); $('#revisionsDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
@@ -429,8 +596,8 @@
return; return;
} }
currentElement.append($('<img>').attr({src: item.url, alt: item.alt || ''})); currentElement.append($('<img>').attr({src: item.url, alt: item.alt || ''}));
markDirty();
$('#mediaDialog').dialog('close'); $('#mediaDialog').dialog('close');
commitEdit();
} }
/** Delete a media file, warning more firmly when public pages still reference it. */ /** Delete a media file, warning more firmly when public pages still reference it. */
@@ -439,6 +606,182 @@
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); openMedia(); } catch (error) { showMessage(error.message, 'error'); } try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); openMedia(); } catch (error) { showMessage(error.message, 'error'); }
} }
// Pages per request: small enough for smooth progress updates, large enough to keep request overhead low.
const SCAN_BATCH = 10;
const APPLY_BATCH = 5;
let siteScanBusy = false;
/** Show the scan progress bar with a file count; without a total the bar is indeterminate. */
function showScanProgress(label, done, total, current) {
const bar = $('#scanProgressBar');
if (total > 0) bar.attr('max', total).val(done); else bar.removeAttr('value');
$('#scanProgressText').text(total > 0
? label + ': ' + done + ' of ' + total + ' pages (' + Math.round(done / total * 100) + '%)' + (current ? ' - ' + current : '')
: label + '...');
$('#siteScanProgress').prop('hidden', false);
}
/** Lock the scan controls while a batch run is in progress. */
function setScanBusy(busy) {
siteScanBusy = busy;
$('#siteScanApply, #siteScanOptions input, #siteScanPages input').prop('disabled', busy);
}
/** Open the scan dialogue and analyse the site. */
async function openSiteScan() {
$('#toolsDialog').dialog('close');
$('#siteScanDialog').dialog('open');
await runSiteScan();
}
/** Analyse every page in small batches so progress and a page count can be shown. */
async function runSiteScan() {
if (siteScanBusy) return;
setScanBusy(true);
$('#siteScanSummary, #siteScanPages').empty();
try {
showScanProgress('Finding pages');
const listing = await api('listSitePages');
const results = [];
const used = new Set();
for (let i = 0; i < listing.pages.length; i += SCAN_BATCH) {
const batch = listing.pages.slice(i, i + SCAN_BATCH);
showScanProgress('Analysing', i, listing.pages.length, batch[0]);
const result = await api('analyseSitePages', {uris: JSON.stringify(batch)});
results.push.apply(results, result.pages);
result.usedUploads.forEach(function (name) { used.add(name); });
}
if (listing.pages.length) showScanProgress('Analysed', listing.pages.length, listing.pages.length);
else $('#scanProgressText').text('No pages found.');
setScanBusy(false);
renderSiteScan(buildSiteScan(results, listing.uploads, used));
} catch (error) {
showMessage(error.message, 'error');
$('#siteScanProgress').prop('hidden', true);
} finally {
setScanBusy(false);
}
}
/** Combine per-page results into the site totals, folder counts, and upload usage shown in the report. */
function buildSiteScan(pages, uploads, usedUploads) {
const summary = {pages: pages.length, editable: 0, needTagging: 0, manual: 0, images: 0, missingAlt: 0, brokenRefs: 0, seoGaps: 0};
const structure = {};
pages.forEach(function (page) {
summary.editable += page.editableRegions > 0 ? 1 : 0;
summary.needTagging += !page.editableRegions && page.proposed.length ? 1 : 0;
summary.manual += !page.editableRegions && !page.proposed.length ? 1 : 0;
summary.images += page.images;
summary.missingAlt += page.missingAlt;
summary.brokenRefs += page.broken.length;
summary.seoGaps += page.seoMissing.some(function (key) { return key === 'title' || key === 'description'; }) ? 1 : 0;
const folder = page.uri.replace(/[^/]*$/, '').replace(/(.)\/$/, '$1');
structure[folder] = (structure[folder] || 0) + 1;
});
return {
summary: summary, structure: structure, pages: pages,
uploads: {files: uploads.length, unused: uploads.filter(function (name) { return !usedUploads.has(name); }).length}
};
}
/** Render scan totals, folder structure, and one selectable row per page. */
function renderSiteScan(scan) {
const s = scan.summary;
const summary = $('#siteScanSummary').empty();
const stats = $('<div class="stat-row">').appendTo(summary);
[
[s.pages, 'pages found', ''], [s.editable, 'already editable', ''], [s.needTagging, 'can be made editable', ''],
[s.manual, 'need manual tagging', 'No safe content area was found on these pages.'],
[s.images + ' (' + s.missingAlt + ' without alt)', 'images', ''],
[s.brokenRefs, 'broken references', 'Local image, CSS, or script files that do not exist.'],
[s.seoGaps, 'missing title or description', ''], [scan.uploads.files + ' (' + scan.uploads.unused + ' unused)', 'uploaded files', '']
].forEach(function (stat) { $('<div class="stat">').attr('title', stat[2]).append($('<strong>').text(stat[0]), document.createTextNode(' ' + stat[1])).appendTo(stats); });
$('<p class="scan-note">').text('Folders: ' + Object.keys(scan.structure).map(function (dir) { return dir + ' (' + scan.structure[dir] + ')'; }).join(', ')).appendTo(summary);
// After the first run only pages with something new to do are listed; finished pages stay out of the way.
const pending = scan.pages.filter(function (page) { return page.changes.length > 0; });
const list = $('#siteScanPages').empty();
if (!pending.length) {
$('<p class="success-text">').text('Nothing new to change: all ' + scan.pages.length + ' page(s) are up to date.').appendTo(list);
} else {
$('<p>').text(pending.length + ' page(s) have changes to make (' + (scan.pages.length - pending.length) + ' already up to date):').appendTo(list);
const all = $('<input type="checkbox" checked aria-label="Select all pages">');
const body = dataTable(list, [all, 'Page', 'Changes to make']);
all.on('change', function () { body.find('input:not(:disabled)').prop('checked', this.checked); });
pending.forEach(function (page) {
const row = $('<tr class="page-row">');
const box = $('<input type="checkbox" checked>').val(page.uri).on('click', function (event) { event.stopPropagation(); });
row.on('click', function () { if (!box.prop('disabled')) box.prop('checked', !box.prop('checked')); });
$('<td class="check-cell">').append(box).appendTo(row);
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(page.uri)).appendTo(row);
if (page.title) $('<span class="page-sub">').text(page.title).appendTo(name);
$('<td class="page-title">').text(page.changes.join('; ')).appendTo(row);
body.append(row);
});
}
$('#siteScanApply').prop('disabled', !pending.length);
}
/** Add the chosen markers to the ticked pages in batches, showing progress, then refresh the report. */
async function applySiteScan() {
if (siteScanBusy) return;
const uris = $('#siteScanPages tbody input:checked').map(function () { return $(this).val(); }).get();
if (!uris.length) { showMessage('Select at least one page.', 'error'); return; }
if (!window.confirm('Add editing markers to ' + uris.length + ' page(s)? A revision of each is kept.')) return;
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked')};
setScanBusy(true);
let updated = 0;
try {
for (let i = 0; i < uris.length; i += APPLY_BATCH) {
const batch = uris.slice(i, i + APPLY_BATCH);
showScanProgress('Updating', i, uris.length, batch[0]);
updated += (await api('applySiteTagging', {uris: JSON.stringify(batch), options: JSON.stringify(options)}, 'POST')).updated_pages;
}
showScanProgress('Updated', uris.length, uris.length);
showMessage('Updated ' + updated + ' page(s). Each has a revision to restore.', 'success');
} catch (error) {
showMessage(error.message + ' (' + updated + ' page(s) were updated before this error.)', 'error');
}
setScanBusy(false);
// Reload the preview so it picks up the new markers, unless that would discard unpublished edits.
if (!dirty) document.getElementById('frameContainer').contentWindow.location.reload();
await runSiteScan();
}
/** Open the image dialogue for an image outside any editable region, offering the media library. */
async function openImageEditor(image) {
currentImage = image;
// A placeholder has no image yet, so it starts empty.
$('#imageSrc').val(image.is('img') ? image.attr('src') || '' : '');
$('#imageAlt').val(image.is('img') ? image.attr('alt') || '' : '');
const picker = $('#imagePicker').empty();
try {
(await api('media')).forEach(function (item) {
$('<img>').attr({src: item.url, alt: item.alt || '', title: item.name}).on('click', function () {
$('#imageSrc').val(item.url);
if (!$('#imageAlt').val()) $('#imageAlt').val(item.alt || '');
}).appendTo(picker);
});
} catch (error) { showMessage(error.message, 'error'); }
$('#imageDialog').dialog('open');
}
/** Apply the dialogue's image address and alternative text to the preview. */
function applyImage(event) {
event.preventDefault();
if (!currentImage) return;
if (currentImage.is('img')) {
currentImage.attr({src: $('#imageSrc').val(), alt: $('#imageAlt').val()});
} else {
// Fill the placeholder's own shape (size, ratio, rounded corners) with the chosen image.
const photo = $('<img data-neo-image>').attr({src: $('#imageSrc').val(), alt: $('#imageAlt').val()})
.css({display: 'block', width: '100%', height: '100%', 'object-fit': 'cover', 'border-radius': 'inherit'});
currentImage.removeAttr('role aria-label data-neo-image').empty().append(photo);
}
currentImage = null;
$('#imageDialog').dialog('close');
commitEdit();
}
/** Read current document metadata into the SEO form. */ /** Read current document metadata into the SEO form. */
function openSeo() { function openSeo() {
if (!iframeDoc) return; if (!iframeDoc) return;
@@ -462,9 +805,8 @@
let canonical = $(iframeDoc).find('link[rel="canonical"]'); let canonical = $(iframeDoc).find('link[rel="canonical"]');
if (!canonical.length) canonical = $('<link rel="canonical">').appendTo(iframeDoc.head); if (!canonical.length) canonical = $('<link rel="canonical">').appendTo(iframeDoc.head);
canonical.attr('href', $('#seoCanonical').val()); canonical.attr('href', $('#seoCanonical').val());
markDirty();
$('#seoDialog').dialog('close'); $('#seoDialog').dialog('close');
showMessage('SEO settings applied. Publish the page to make them live.', 'success'); commitEdit('SEO settings saved to the draft.' + (permissions.publish ? ' Publish to make them live.' : ''));
} }
/** Return a named metadata value, or an empty string when the tag is absent. */ /** Return a named metadata value, or an empty string when the tag is absent. */
@@ -520,12 +862,21 @@
async function openShared() { async function openShared() {
try { try {
const blocks = await api('shared'); const blocks = await api('shared');
const keys = Object.keys(blocks);
const list = $('#sharedList').empty(); const list = $('#sharedList').empty();
Object.keys(blocks).forEach(function (key) { if (!keys.length) {
$('<button class="list-row">').text(key + ' - updated ' + new Date(blocks[key].updated).toLocaleString()).on('click', function () { $('<p class="empty-state">').text('No shared blocks yet. Use the form below to create one.').appendTo(list);
$('#sharedKey').val(key); $('#sharedContent').val(blocks[key].content); } else {
}).appendTo(list); const body = dataTable(list, ['Block', 'Last updated', 'Updated by', 'Actions']);
keys.forEach(function (key) {
const load = function () { $('#sharedKey').val(key); $('#sharedContent').val(blocks[key].content); };
const row = $('<tr class="page-row">').on('click', load);
$('<td class="page-cell">').append(docIcon(), $('<strong>').text(key)).appendTo(row);
row.append($('<td class="page-date">').text(formatDate(blocks[key].updated)), $('<td>').text(blocks[key].user || ''),
$('<td class="row-actions">').append(rowButton('Edit', load)));
body.append(row);
}); });
}
$('#toolsDialog').dialog('close'); $('#toolsDialog').dialog('close');
$('#sharedDialog').dialog('open'); $('#sharedDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
@@ -539,9 +890,10 @@
const result = await api('saveShared', {key: key, content: $('#sharedContent').val()}, 'POST'); const result = await api('saveShared', {key: key, content: $('#sharedContent').val()}, 'POST');
if (currentElement && window.confirm('Mark the currently selected editable region as this shared block?')) { if (currentElement && window.confirm('Mark the currently selected editable region as this shared block?')) {
currentElement.attr('data-neo-shared', key).html($('#sharedContent').val()); currentElement.attr('data-neo-shared', key).html($('#sharedContent').val());
markDirty(); await commitEdit(result.message + ' This page was also saved as a draft.');
} } else {
showMessage(result.message, 'success'); showMessage(result.message, 'success');
}
openShared(); openShared();
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
} }
@@ -550,13 +902,24 @@
async function openMenus() { async function openMenus() {
try { try {
const menus = await api('menus'); const menus = await api('menus');
const names = Object.keys(menus);
const list = $('#menuList').empty(); const list = $('#menuList').empty();
Object.keys(menus).forEach(function (name) { if (!names.length) {
$('<button class="list-row">').text(name).on('click', function () { $('<p class="empty-state">').text('No menus yet. Use the form below to create one.').appendTo(list);
} else {
const body = dataTable(list, ['Menu', 'Items', 'Last updated', 'Actions']);
names.forEach(function (name) {
const load = function () {
$('#menuName').val(name); $('#menuName').val(name);
$('#menuItems').val(menus[name].items.map(function (item) { return item.label + ' | ' + item.url + (item.parent ? ' | ' + item.parent : ''); }).join('\n')); $('#menuItems').val(menus[name].items.map(function (item) { return item.label + ' | ' + item.url + (item.parent ? ' | ' + item.parent : ''); }).join('\n'));
}).appendTo(list); };
const row = $('<tr class="page-row">').on('click', load);
$('<td class="page-cell">').append(docIcon(), $('<strong>').text(name)).appendTo(row);
row.append($('<td>').text(menus[name].items.length), $('<td class="page-date">').text(formatDate(menus[name].updated)),
$('<td class="row-actions">').append(rowButton('Edit', load)));
body.append(row);
}); });
}
$('#toolsDialog').dialog('close'); $('#toolsDialog').dialog('close');
$('#menusDialog').dialog('open'); $('#menusDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
@@ -572,9 +935,11 @@
try { try {
const result = await api('saveMenu', {name: $('#menuName').val(), items: JSON.stringify(items)}, 'POST'); const result = await api('saveMenu', {name: $('#menuName').val(), items: JSON.stringify(items)}, 'POST');
if (currentElement && window.confirm('Insert this menu into the currently selected editable region?')) { if (currentElement && window.confirm('Insert this menu into the currently selected editable region?')) {
currentElement.html(result.html); markDirty(); currentElement.html(result.html);
} await commitEdit(result.message + ' This page was also saved as a draft.');
} else {
showMessage(result.message, 'success'); showMessage(result.message, 'success');
}
openMenus(); openMenus();
} catch (error) { showMessage(error.message, 'error'); } } catch (error) { showMessage(error.message, 'error'); }
} }
@@ -589,20 +954,51 @@
permissions = dashboard.permissions; permissions = dashboard.permissions;
$('.publish-only').toggle(permissions.publish); $('.publish-only').toggle(permissions.publish);
$('.manage-only').toggle(permissions.manage); $('.manage-only').toggle(permissions.manage);
$('#saveDraft').toggle(permissions.draft);
const content = $('#dashboardContent').empty(); const content = $('#dashboardContent').empty();
$('<h3>').text('Pending work').appendTo(content); const draftUris = Object.keys(dashboard.drafts);
$('<p>').text(Object.keys(dashboard.drafts).length + ' draft(s), ' + Object.keys(dashboard.schedules).length + ' scheduled publication(s).').appendTo(content); const jobIds = Object.keys(dashboard.schedules);
Object.keys(dashboard.schedules).forEach(function (id) {
$('<div class="stat-row">').append(
$('<div class="stat">').append($('<strong>').text(draftUris.length), document.createTextNode(' draft(s)')),
$('<div class="stat">').append($('<strong>').text(jobIds.length), document.createTextNode(' scheduled publication(s)'))
).appendTo(content);
if (jobIds.length) {
$('<h3 class="dialog-section">').text('Scheduled publications').appendTo(content);
const body = dataTable(content, permissions.schedule ? ['Page', 'Publishes', 'Status', 'Actions'] : ['Page', 'Publishes', 'Status']);
jobIds.forEach(function (id) {
const job = dashboard.schedules[id]; const job = dashboard.schedules[id];
const row = $('<div class="list-row">').text(job.uri + ' - ' + new Date(job.publish_at).toLocaleString() + ' '); const row = $('<tr>');
if (permissions.schedule) $('<button>').text('Cancel').on('click', function () { cancelSchedule(id); }).appendTo(row); $('<td class="page-cell">').append(docIcon(), document.createTextNode(job.uri)).appendTo(row);
content.append(row); row.append($('<td class="page-date">').text(formatDate(job.publish_at)),
$('<td>').append($('<span>').addClass(job.status === 'failed' ? 'badge-failed' : 'badge-pending').text(job.status === 'failed' ? 'Failed' : 'Pending')));
if (permissions.schedule) $('<td class="row-actions">').append(rowButton('Cancel', function () { cancelSchedule(id); })).appendTo(row);
body.append(row);
}); });
$('<h3>').text('Recent activity').appendTo(content); }
dashboard.activity.forEach(function (entry) { $('<div class="list-row">').text(new Date(entry.created).toLocaleString() + ' - ' + entry.user + ' - ' + entry.action + ': ' + entry.target).appendTo(content); }); if (draftUris.length) {
$('<h3 class="dialog-section">').text('Drafts').appendTo(content);
const body = dataTable(content, ['Page', 'Saved', 'By']);
draftUris.forEach(function (uri) {
const row = $('<tr>');
$('<td class="page-cell">').append(docIcon(), document.createTextNode(uri)).appendTo(row);
row.append($('<td class="page-date">').text(formatDate(dashboard.drafts[uri].updated)), $('<td>').text(dashboard.drafts[uri].user || ''));
body.append(row);
});
}
$('<h3 class="dialog-section">').text('Recent activity').appendTo(content);
if (!dashboard.activity.length) {
$('<p class="empty-state">').text('No activity has been recorded yet.').appendTo(content);
} else {
const body = dataTable(content, ['When', 'User', 'Action', 'Target']);
dashboard.activity.forEach(function (entry) {
body.append($('<tr>').append($('<td class="page-date">').text(formatDate(entry.created)), $('<td>').text(entry.user),
$('<td>').text(entry.action), $('<td class="page-title">').text(entry.target)));
});
}
if (dashboard.problems.length) { if (dashboard.problems.length) {
$('<h3>').text('System problems').appendTo(content); $('<h3 class="dialog-section">').text('System problems').appendTo(content);
dashboard.problems.forEach(function (problem) { $('<div class="issue">').text(problem).appendTo(content); }); dashboard.problems.forEach(function (problem) { $('<div class="issue">').text(problem).appendTo(content); });
} }
if (open) $('#dashboardDialog').dialog('open'); if (open) $('#dashboardDialog').dialog('open');
@@ -617,7 +1013,7 @@
/** End the session after protecting unpublished browser changes. */ /** End the session after protecting unpublished browser changes. */
async function logout() { async function logout() {
if (dirty && !window.confirm('Log out and discard unpublished changes?')) return; if (dirty && !window.confirm('Log out and discard unpublished changes?')) return;
try { await api('logout', {}, 'POST'); window.location.href = '/cms/login/'; } catch (error) { showMessage(error.message, 'error'); } try { await api('logout', {}, 'POST'); window.location.href = basePath + '/cms/login/'; } catch (error) { showMessage(error.message, 'error'); }
} }
/** Mark the preview as unpublished and refresh its toolbar status. */ /** Mark the preview as unpublished and refresh its toolbar status. */
@@ -628,7 +1024,7 @@
/** Display the active path and whether browser changes remain unpublished. */ /** Display the active path and whether browser changes remain unpublished. */
function updateUrl() { function updateUrl() {
$('#urlbox').empty().append($('<strong>').text(dirty ? 'Unpublished: ' : 'Editing: '), $('<span>').text(iframeDoc ? iframeDoc.location.pathname : '')); $('#urlbox').empty().append($('<strong>').text(dirty ? 'Unsaved: ' : draftSaved ? 'Draft: ' : 'Editing: '), $('<span>').text(iframeDoc ? framePath : ''));
} }
/** Format a byte count compactly for media cards. */ /** Format a byte count compactly for media cards. */
function formatBytes(bytes) { function formatBytes(bytes) {
@@ -639,8 +1035,22 @@
})(jQuery); })(jQuery);
/** Display a temporary, colour-coded status message above the administration interface. */ /** Display a temporary, colour-coded status message above the administration interface. */
/** Show a success or error snackbar; errors stay longer and are announced to assistive technology immediately. */
function showMessage(message, type) { function showMessage(message, type) {
const bar = jQuery('#message-bar'); const bar = jQuery('#message-bar');
bar.stop(true, true).text(message || 'Done').css({backgroundColor: type === 'error' ? '#a51d2d' : '#18733c'}).slideDown(); const isError = type === 'error';
window.setTimeout(function () { bar.slideUp(); }, 5000); bar.attr('role', isError ? 'alert' : 'status').removeClass('show is-success is-error').addClass(isError ? 'is-error' : 'is-success').prop('hidden', false);
bar.find('.snack-text').text(message || 'Done');
void bar[0].offsetWidth; // Restart the transition when a message replaces another.
bar.addClass('show');
window.clearTimeout(showMessage.timer);
showMessage.timer = window.setTimeout(hideMessage, isError ? 9000 : 5000);
} }
/** Slide the snackbar away, then remove it from the layout. */
function hideMessage() {
const bar = jQuery('#message-bar').removeClass('show');
window.setTimeout(function () { if (!bar.hasClass('show')) bar.prop('hidden', true); }, 250);
}
jQuery(function () { jQuery('#message-bar .snack-close').on('click', hideMessage); });
+4 -10
View File
@@ -2,13 +2,7 @@
/** Login form and authentication request handler. */ /** Login form and authentication request handler. */
// Load account configuration before constructing authentication services. // Load account configuration before constructing authentication services.
require_once "../config.php"; require_once __DIR__ . '/../bootstrap.php';
// Resolve NeoCMS classes without introducing a package-manager requirement.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "../src/{$classPath}.php";
});
use NeoCMS\Authentication; use NeoCMS\Authentication;
use NeoCMS\LoginRateLimiter; use NeoCMS\LoginRateLimiter;
@@ -62,7 +56,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
); );
// Redirect after POST so browser refreshes do not resubmit credentials. // Redirect after POST so browser refreshes do not resubmit credentials.
header("Location: /cms/"); header("Location: " . $config['basePath'] . "/cms/");
exit; exit;
} else { } else {
$rateLimiter->recordFailure($address, $username); $rateLimiter->recordFailure($address, $username);
@@ -81,13 +75,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
<html lang="en-gb"> <html lang="en-gb">
<head> <head>
<title>NeoCMS Login</title> <title>NeoCMS Login</title>
<link rel="stylesheet" href="/cms/css/login.css"/> <link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/login.css"/>
</head> </head>
<body> <body>
<!-- The compact login card is intentionally independent of the heavier administration UI. --> <!-- The compact login card is intentionally independent of the heavier administration UI. -->
<div class="login-container"> <div class="login-container">
<img class="logo" src="/cms/img/loginlogo.png" alt="NeoCMS logo"/> <img class="logo" src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/img/loginlogo.png" alt="NeoCMS logo"/>
<h2>NeoCMS Login</h2> <h2>NeoCMS Login</h2>
<?php if (!empty($error)): ?> <?php if (!empty($error)): ?>
<div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div> <div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
+1 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
// Audit logs are not a public attraction; return to the authenticated CMS instead. // Audit logs are not a public attraction; return to the authenticated CMS instead.
http_response_code(404); http_response_code(404);
header("Location: /cms/"); header("Location: ../");
+2 -8
View File
@@ -6,14 +6,8 @@
* publishing is useful, while a public button marked "publish everything due" is rather less so. * publishing is useful, while a public button marked "publish everything due" is rather less so.
*/ */
// Load the same configuration used by the interactive CMS. // Load the same configuration and autoloader used by the interactive CMS.
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/bootstrap.php';
// Register the small project autoloader because NeoCMS deliberately has no Composer dependency.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once __DIR__ . "/src/{$classPath}.php";
});
// Refuse web requests even if the web server accidentally exposes this script. // Refuse web requests even if the web server accidentally exposes this script.
if (PHP_SAPI !== 'cli') { if (PHP_SAPI !== 'cli') {
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace NeoCMS;
/** Records one operational event in both the Dashboard activity feed and the daily audit log. */
final class Activity
{
public function __construct(private FileStore $store, private Logger $logger)
{
}
public function record(string $user, string $action, string $target): void
{
$entry = ['created' => date(DATE_ATOM), 'user' => $user, 'action' => $action, 'target' => $target];
// Retaining the latest 250 entries keeps the dashboard useful without growing forever.
$this->store->update('activity', function (array $entries) use ($entry) {
$entries[] = $entry;
return array_slice($entries, -250);
});
$this->logger->write($action . ': ' . $target, $user);
}
}
+5 -1
View File
@@ -25,6 +25,9 @@ class Authentication
/** Maximum total lifetime of an authenticated session. */ /** Maximum total lifetime of an authenticated session. */
private int $absoluteTimeout; private int $absoluteTimeout;
/** URL path the session cookie is scoped to; the CMS directory, which moves when the site is in a subfolder. */
private string $cookiePath;
/** Optional explicit Secure-cookie setting for TLS-terminating reverse proxies. */ /** Optional explicit Secure-cookie setting for TLS-terminating reverse proxies. */
private ?bool $cookieSecure; private ?bool $cookieSecure;
@@ -36,6 +39,7 @@ class Authentication
$this->idleTimeout = max(300, (int) ($sessionOptions['idleTimeout'] ?? 1800)); $this->idleTimeout = max(300, (int) ($sessionOptions['idleTimeout'] ?? 1800));
$this->absoluteTimeout = max($this->idleTimeout, (int) ($sessionOptions['absoluteTimeout'] ?? 43200)); $this->absoluteTimeout = max($this->idleTimeout, (int) ($sessionOptions['absoluteTimeout'] ?? 43200));
$this->cookieSecure = isset($sessionOptions['cookieSecure']) ? (bool) $sessionOptions['cookieSecure'] : null; $this->cookieSecure = isset($sessionOptions['cookieSecure']) ? (bool) $sessionOptions['cookieSecure'] : null;
$this->cookiePath = (string) ($sessionOptions['cookiePath'] ?? '/cms');
$this->startSession(); $this->startSession();
$this->enforceSessionLifetime(); $this->enforceSessionLifetime();
} }
@@ -65,7 +69,7 @@ class Authentication
session_set_cookie_params([ session_set_cookie_params([
'httponly' => true, 'httponly' => true,
'path' => '/cms', 'path' => $this->cookiePath,
'samesite' => 'Lax', 'samesite' => 'Lax',
'secure' => $isHttps, 'secure' => $isHttps,
]); ]);
+271 -341
View File
@@ -18,10 +18,12 @@ final class CMSController
private Logger $logger; private Logger $logger;
/** JSON metadata and managed-file storage service. */ /** JSON metadata and managed-file storage service. */
private FileStore $store; private FileStore $store;
/** Dashboard activity feed and audit-log writer. */
private Activity $activity;
/** Absolute directory containing page templates. */ /** Absolute directory containing page templates. */
private string $templatesDir; private string $templatesDir;
/** Canonical public document root, ending with a directory separator. */ /** Public-page URI/path resolution confined to the document root. */
private string $documentRoot; private PagePaths $paths;
/** Validated CSS class used to discover and bind editable content regions. */ /** Validated CSS class used to discover and bind editable content regions. */
private string $editableClass; private string $editableClass;
/** Maximum complete HTML document size accepted from an authenticated author. */ /** Maximum complete HTML document size accepted from an authenticated author. */
@@ -30,8 +32,6 @@ final class CMSController
private int $maxRequestBytes; private int $maxRequestBytes;
/** Safety ceiling for recursive public-page scans. */ /** Safety ceiling for recursive public-page scans. */
private int $maxManagedPages; private int $maxManagedPages;
/** Safety ceiling for all directory entries visited during recursive page scans. */
private int $maxScannedEntries;
/** Maximum aggregate private draft storage. */ /** Maximum aggregate private draft storage. */
private int $maxDraftBytes; private int $maxDraftBytes;
/** Maximum number of queued or failed scheduled publications. */ /** Maximum number of queued or failed scheduled publications. */
@@ -54,18 +54,22 @@ final class CMSController
// A custom data directory is primarily useful for tests and hardened deployments. // A custom data directory is primarily useful for tests and hardened deployments.
$dataDirectory = $config['dataDirectory'] ?? (__DIR__ . '/../../data'); $dataDirectory = $config['dataDirectory'] ?? (__DIR__ . '/../../data');
$this->store = new FileStore((string) $dataDirectory); $this->store = new FileStore((string) $dataDirectory);
$this->activity = new Activity($this->store, $this->logger);
$templatesDir = realpath(__DIR__ . '/../../templates'); $templatesDir = realpath(__DIR__ . '/../../templates');
$documentRoot = realpath((string) ($_SERVER['DOCUMENT_ROOT'] ?? '')); $documentRoot = realpath((string) ($config['siteRoot'] ?? $_SERVER['DOCUMENT_ROOT'] ?? ''));
if ($templatesDir === false || $documentRoot === false) { if ($templatesDir === false || $documentRoot === false) {
throw new \RuntimeException('CMS filesystem paths are unavailable'); throw new \RuntimeException('CMS filesystem paths are unavailable');
} }
$this->templatesDir = rtrim($templatesDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $this->templatesDir = rtrim($templatesDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$this->documentRoot = rtrim($documentRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$security = is_array($config['security'] ?? null) ? $config['security'] : []; $security = is_array($config['security'] ?? null) ? $config['security'] : [];
$this->maxContentBytes = max(1024, (int) ($security['maxContentBytes'] ?? 5 * 1024 * 1024)); $this->maxContentBytes = max(1024, (int) ($security['maxContentBytes'] ?? 5 * 1024 * 1024));
$this->maxRequestBytes = max($this->maxContentBytes, (int) ($security['maxRequestBytes'] ?? 6 * 1024 * 1024)); $this->maxRequestBytes = max($this->maxContentBytes, (int) ($security['maxRequestBytes'] ?? 6 * 1024 * 1024));
$this->maxManagedPages = max(1, (int) ($security['maxManagedPages'] ?? 5000)); $this->maxManagedPages = max(1, (int) ($security['maxManagedPages'] ?? 5000));
$this->maxScannedEntries = max($this->maxManagedPages, (int) ($security['maxScannedEntries'] ?? 20000)); $this->paths = new PagePaths(
rtrim($documentRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR,
$this->maxManagedPages,
max($this->maxManagedPages, (int) ($security['maxScannedEntries'] ?? 20000))
);
$this->maxDraftBytes = max($this->maxContentBytes, (int) ($security['maxDraftBytes'] ?? 250 * 1024 * 1024)); $this->maxDraftBytes = max($this->maxContentBytes, (int) ($security['maxDraftBytes'] ?? 250 * 1024 * 1024));
$this->maxSchedules = max(1, (int) ($security['maxSchedules'] ?? 100)); $this->maxSchedules = max(1, (int) ($security['maxSchedules'] ?? 100));
$this->maxScheduledBytes = max($this->maxContentBytes, (int) ($security['maxScheduledBytes'] ?? 250 * 1024 * 1024)); $this->maxScheduledBytes = max($this->maxContentBytes, (int) ($security['maxScheduledBytes'] ?? 250 * 1024 * 1024));
@@ -97,8 +101,13 @@ final class CMSController
return; return;
} }
try {
// A failure here must not break unrelated requests; the cron worker will retry.
try { try {
$this->publishDueJobs(); $this->publishDueJobs();
} catch (\Throwable $exception) {
$this->logger->write("Opportunistic schedule run failed: {$exception->getMessage()}", $this->user());
}
// Action names map to private methods such as saveDraftAction(). // Action names map to private methods such as saveDraftAction().
$method = $action . 'Action'; $method = $action . 'Action';
if ($action === '' || !method_exists($this, $method)) { if ($action === '' || !method_exists($this, $method)) {
@@ -138,7 +147,7 @@ final class CMSController
$this->requirePost('draft'); $this->requirePost('draft');
$uri = $this->requiredPost('uri'); $uri = $this->requiredPost('uri');
$content = $this->requiredContentPost('content'); $content = $this->requiredContentPost('content');
$draftName = hash('sha256', $this->normaliseUri($uri)) . '.html'; $draftName = hash('sha256', $this->paths->normaliseUri($uri)) . '.html';
$draftPath = $this->store->directory('drafts') . $draftName; $draftPath = $this->store->directory('drafts') . $draftName;
$draftBytes = $this->directoryBytes('drafts') - (is_file($draftPath) ? (int) filesize($draftPath) : 0); $draftBytes = $this->directoryBytes('drafts') - (is_file($draftPath) ? (int) filesize($draftPath) : 0);
if ($draftBytes + strlen($content) > $this->maxDraftBytes) { if ($draftBytes + strlen($content) > $this->maxDraftBytes) {
@@ -146,12 +155,12 @@ final class CMSController
} }
$this->store->writePrivateFile('drafts', $draftName, $content); $this->store->writePrivateFile('drafts', $draftName, $content);
// Draft filenames are URI hashes, while this index keeps their human-readable metadata. // Draft filenames are URI hashes, while this index keeps their human-readable metadata.
$drafts = $this->store->read('drafts'); $draftUri = $this->paths->normaliseUri($uri);
$drafts[$this->normaliseUri($uri)] = [ $entry = ['updated' => date(DATE_ATOM), 'user' => $this->user()];
'updated' => date(DATE_ATOM), $this->store->update('drafts', function (array $drafts) use ($draftUri, $entry) {
'user' => $this->user(), $drafts[$draftUri] = $entry;
]; return $drafts;
$this->store->write('drafts', $drafts); });
$this->activity('Saved draft', $uri); $this->activity('Saved draft', $uri);
$this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]); $this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]);
} }
@@ -170,20 +179,21 @@ final class CMSController
$this->requirePost('schedule'); $this->requirePost('schedule');
$uri = $this->requiredPost('uri'); $uri = $this->requiredPost('uri');
$content = $this->requiredContentPost('content'); $content = $this->requiredContentPost('content');
$this->existingPagePath($uri); $this->paths->existing($uri);
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at')); $publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
if ($publishAt <= new \DateTimeImmutable()) { if ($publishAt <= new \DateTimeImmutable()) {
throw new \RuntimeException('Publish time must be in the future'); throw new \RuntimeException('Publish time must be in the future');
} }
// Store bulky HTML separately so the JSON schedule index remains easy to inspect. // Store bulky HTML separately so the JSON schedule index remains easy to inspect.
$id = bin2hex(random_bytes(10)); $id = bin2hex(random_bytes(10));
$jobs = $this->store->read('schedules'); $this->store->update('schedules', function (array $jobs) use ($id, $uri, $content, $publishAt) {
if (count($jobs) >= $this->maxSchedules || $this->directoryBytes('scheduled') + strlen($content) > $this->maxScheduledBytes) { if (count($jobs) >= $this->maxSchedules || $this->directoryBytes('scheduled') + strlen($content) > $this->maxScheduledBytes) {
throw new \RuntimeException('Scheduled publication quota has been reached'); throw new \RuntimeException('Scheduled publication quota has been reached');
} }
$this->store->writePrivateFile('scheduled', $id . '.html', $content); $this->store->writePrivateFile('scheduled', $id . '.html', $content);
$jobs[$id] = ['uri' => $this->normaliseUri($uri), 'publish_at' => $publishAt->format(DATE_ATOM), 'user' => $this->user()]; $jobs[$id] = ['uri' => $this->paths->normaliseUri($uri), 'publish_at' => $publishAt->format(DATE_ATOM), 'user' => $this->user()];
$this->store->write('schedules', $jobs); return $jobs;
});
$this->activity('Scheduled publication', $uri); $this->activity('Scheduled publication', $uri);
$this->respond(['message' => 'Page scheduled', 'id' => $id, 'publish_at' => $publishAt->format(DATE_ATOM)]); $this->respond(['message' => 'Page scheduled', 'id' => $id, 'publish_at' => $publishAt->format(DATE_ATOM)]);
} }
@@ -193,10 +203,16 @@ final class CMSController
{ {
$this->requirePost('schedule'); $this->requirePost('schedule');
$id = $this->requiredPost('id'); $id = $this->requiredPost('id');
$jobs = $this->store->read('schedules'); $removed = null;
$this->store->update('schedules', function (array $jobs) use ($id, &$removed) {
$removed = $jobs[$id] ?? null;
unset($jobs[$id]); unset($jobs[$id]);
return $jobs;
});
@unlink($this->store->directory('scheduled') . basename($id) . '.html'); @unlink($this->store->directory('scheduled') . basename($id) . '.html');
$this->store->write('schedules', $jobs); if ($removed) {
$this->activity('Cancelled scheduled publication', ($removed['uri'] ?? '') . ' (was due ' . ($removed['publish_at'] ?? '?') . ', scheduled by ' . ($removed['user'] ?? 'unknown') . ')');
}
$this->respond(['message' => 'Schedule cancelled']); $this->respond(['message' => 'Schedule cancelled']);
} }
@@ -216,21 +232,21 @@ final class CMSController
private function newPageAction(): void private function newPageAction(): void
{ {
$this->requirePost('manage'); $this->requirePost('manage');
$uri = $this->normaliseNewPageUri($this->requiredPost('filename')); $uri = $this->paths->normaliseNewUri($this->requiredPost('filename'));
$template = basename($this->requiredPost('template')); $template = basename($this->requiredPost('template'));
$source = realpath($this->templatesDir . $template); $source = realpath($this->templatesDir . $template);
if (!$source || !str_starts_with($source, $this->templatesDir)) { if (!$source || !str_starts_with($source, $this->templatesDir)) {
throw new \RuntimeException('Template not found'); throw new \RuntimeException('Template not found');
} }
if (count($this->pageFiles()) >= $this->maxManagedPages) { if (count($this->paths->files()) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit has been reached'); throw new \RuntimeException('Managed page limit has been reached');
} }
$destination = $this->newPagePath($uri); $destination = $this->paths->newPath($uri);
if (file_exists($destination)) { if (file_exists($destination)) {
throw new \RuntimeException('Page already exists'); throw new \RuntimeException('Page already exists');
} }
$this->ensureParentDirectory($destination); $this->ensureParentDirectory($destination);
$this->assertNoSymlinkComponents($destination); $this->paths->assertNoSymlinks($destination);
if (!copy($source, $destination)) { if (!copy($source, $destination)) {
throw new \RuntimeException('Template copy failed'); throw new \RuntimeException('Template copy failed');
} }
@@ -243,8 +259,8 @@ final class CMSController
{ {
$this->requirePost('manage'); $this->requirePost('manage');
$operation = $this->requiredPost('operation'); $operation = $this->requiredPost('operation');
$sourceUri = $this->normaliseUri($this->requiredPost('uri')); $sourceUri = $this->paths->normaliseUri($this->requiredPost('uri'));
$source = $this->existingPagePath($sourceUri); $source = $this->paths->existing($sourceUri);
// Deletion gets its own branch because it has no target URI. // Deletion gets its own branch because it has no target URI.
if ($operation === 'delete') { if ($operation === 'delete') {
@@ -257,13 +273,13 @@ final class CMSController
return; return;
} }
$targetUri = $this->normaliseNewPageUri($this->requiredPost('target')); $targetUri = $this->paths->normaliseNewUri($this->requiredPost('target'));
$target = $this->newPagePath($targetUri); $target = $this->paths->newPath($targetUri);
if (file_exists($target)) { if (file_exists($target)) {
throw new \RuntimeException('Target page already exists'); throw new \RuntimeException('Target page already exists');
} }
$this->ensureParentDirectory($target); $this->ensureParentDirectory($target);
$this->assertNoSymlinkComponents($target); $this->paths->assertNoSymlinks($target);
$ok = $operation === 'rename' ? rename($source, $target) : ($operation === 'duplicate' && copy($source, $target)); $ok = $operation === 'rename' ? rename($source, $target) : ($operation === 'duplicate' && copy($source, $target));
if (!$ok) { if (!$ok) {
throw new \RuntimeException('Page operation failed'); throw new \RuntimeException('Page operation failed');
@@ -277,10 +293,10 @@ final class CMSController
{ {
$drafts = $this->store->read('drafts'); $drafts = $this->store->read('drafts');
$pages = []; $pages = [];
foreach ($this->pageFiles() as $path) { foreach ($this->paths->files() as $path) {
$uri = '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot))); $uri = $this->paths->uriFor($path);
$html = (string) file_get_contents($path); $html = (string) file_get_contents($path);
if (!$this->hasEditableClass($html)) { if (!ContentDom::hasClass($html, $this->editableClass)) {
continue; continue;
} }
$pages[] = [ $pages[] = [
@@ -298,9 +314,9 @@ final class CMSController
/** List newest-first revisions belonging to one page URI. */ /** List newest-first revisions belonging to one page URI. */
private function revisionsAction(): void private function revisionsAction(): void
{ {
$uri = $this->normaliseUri($this->requiredRequest('uri')); $uri = $this->paths->normaliseUri($this->requiredRequest('uri'));
$items = array_values(array_filter($this->store->read('revisions'), fn(array $item) => $item['uri'] === $uri)); $items = array_values(array_filter($this->store->read('revisions'), fn(array $item) => $item['uri'] === $uri));
usort($items, fn(array $a, array $b) => strcmp($b['created'], $a['created'])); usort($items, fn(array $a, array $b) => self::revisionTime($b) <=> self::revisionTime($a));
$this->respond($items); $this->respond($items);
} }
@@ -318,14 +334,11 @@ final class CMSController
$content = (string) file_get_contents($path); $content = (string) file_get_contents($path);
try { try {
$this->publishContent($revision['uri'], $content, 'Restored revision'); $this->publishContent($revision['uri'], $content, 'Restored revision');
} catch (\RuntimeException $exception) { } catch (PageNotFoundException) {
// A missing target is expected when restoring the revision of a deleted page. // A missing target is expected when restoring the revision of a deleted page.
if ($exception->getMessage() !== 'Invalid page path') { $destination = $this->paths->newPath($this->paths->normaliseNewUri($revision['uri']));
throw $exception;
}
$destination = $this->newPagePath($this->normaliseNewPageUri($revision['uri']));
$this->ensureParentDirectory($destination); $this->ensureParentDirectory($destination);
$this->assertNoSymlinkComponents($destination); $this->paths->assertNoSymlinks($destination);
if (file_put_contents($destination, $content, LOCK_EX) === false) { if (file_put_contents($destination, $content, LOCK_EX) === false) {
throw new \RuntimeException('Unable to restore deleted page'); throw new \RuntimeException('Unable to restore deleted page');
} }
@@ -334,6 +347,94 @@ final class CMSController
$this->respond(['message' => 'Revision restored', 'url' => $revision['uri']]); $this->respond(['message' => 'Revision restored', 'url' => $revision['uri']]);
} }
/** List every managed page and uploaded image so the browser can analyse them in small, countable batches. */
private function listSitePagesAction(): void
{
$this->requireCapability('manage');
$uploads = [];
foreach (glob(dirname(__DIR__, 3) . '/uploads/*') ?: [] as $file) {
if (is_file($file) && $this->isManagedMediaName(basename($file))) {
$uploads[] = basename($file);
}
}
$pages = array_map(fn(string $path) => $this->paths->uriFor($path), $this->paths->files());
sort($pages);
$this->respond(['pages' => $pages, 'uploads' => $uploads]);
}
/** Report what a site scan would change for one batch of pages: regions, images, references, and SEO gaps. */
private function analyseSitePagesAction(): void
{
$this->requireCapability('manage');
$uris = $this->uriBatch($this->requiredRequest('uris'));
$basePath = (string) ($this->config['basePath'] ?? '');
$all = ['content' => true, 'images' => true, 'seo' => true];
$pages = [];
$usedUploads = [];
foreach ($uris as $uri) {
try {
$path = $this->paths->existing($uri);
} catch (PageNotFoundException) {
continue;
}
$html = (string) file_get_contents($path);
$a = SiteAnalyser::analyse($html, $this->editableClass);
$broken = [];
foreach ($a['refs'] as $ref) {
if ($this->paths->refExists($path, $ref['url'], $basePath) === false) {
$broken[] = $ref['url'];
}
}
if (preg_match_all('#/uploads/([a-f0-9]{32}\.(?:jpg|png|gif|webp))#', $html, $matches)) {
array_push($usedUploads, ...$matches[1]);
}
$plan = SiteAnalyser::apply($html, $this->editableClass, $all);
$pages[] = [
'uri' => $this->paths->uriFor($path), 'title' => $a['title'], 'editableRegions' => $a['existingRegions'], 'proposed' => $a['proposed'],
'images' => count($a['images']), 'missingAlt' => count(array_filter($a['images'], fn($i) => !$i['hasAlt'])),
'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
'changes' => $plan['changes'] ?? [],
];
}
$this->respond(['pages' => $pages, 'usedUploads' => array_values(array_unique($usedUploads))]);
}
/** Decode a JSON list of page URIs, normalising each and bounding the batch size. */
private function uriBatch(string $json): array
{
$uris = json_decode($json, true);
if (!is_array($uris) || !$uris) {
throw new \RuntimeException('Select at least one page');
}
if (count($uris) > 100) {
throw new \RuntimeException('Too many pages in one request');
}
return array_values(array_unique(array_map(fn($uri) => $this->paths->normaliseUri((string) $uri), $uris)));
}
/** Add editing markers (content regions, image markers, missing SEO tags) to the selected pages. */
private function applySiteTaggingAction(): void
{
$this->requirePost('manage');
$uris = $this->uriBatch($this->limitedPost('uris', 1024 * 1024));
$options = json_decode($this->limitedPost('options', 4096), true);
if (!is_array($options)) {
throw new \RuntimeException('Choose at least one kind of change');
}
$flags = ['content' => !empty($options['content']), 'images' => !empty($options['images']), 'seo' => !empty($options['seo'])];
if (!array_filter($flags)) {
throw new \RuntimeException('Choose at least one kind of change');
}
$pages = [];
$count = $this->rewritePages(function (string $html, string $uri) use ($flags, &$pages) {
$result = SiteAnalyser::apply($html, $this->editableClass, $flags);
if ($result) {
$pages[$uri] = $result['changes'];
}
return $result['html'] ?? null;
}, 'Before auto-tagging', $uris);
$this->activity('Auto-tagged pages', $count . ' page(s)');
$this->respond(['message' => "Updated {$count} page(s). Each has a revision to restore.", 'updated_pages' => $count, 'pages' => $pages]);
}
/** Return the complete shared-content registry. */ /** Return the complete shared-content registry. */
private function sharedAction(): void private function sharedAction(): void
{ {
@@ -349,9 +450,11 @@ final class CMSController
if ($key === '') { if ($key === '') {
throw new \RuntimeException('Shared block name is required'); throw new \RuntimeException('Shared block name is required');
} }
$shared = $this->store->read('shared'); $entry = ['content' => $content, 'updated' => date(DATE_ATOM), 'user' => $this->user()];
$shared[$key] = ['content' => $content, 'updated' => date(DATE_ATOM), 'user' => $this->user()]; $this->store->update('shared', function (array $shared) use ($key, $entry) {
$this->store->write('shared', $shared); $shared[$key] = $entry;
return $shared;
});
$updated = $this->propagateSharedBlock($key, $content); $updated = $this->propagateSharedBlock($key, $content);
$this->activity('Updated shared block', $key); $this->activity('Updated shared block', $key);
$this->respond(['message' => "Shared block updated on {$updated} page(s)", 'updated_pages' => $updated]); $this->respond(['message' => "Shared block updated on {$updated} page(s)", 'updated_pages' => $updated]);
@@ -378,12 +481,13 @@ final class CMSController
if (!is_array($item) || empty($item['url'])) { if (!is_array($item) || empty($item['url'])) {
continue; continue;
} }
$clean[] = ['label' => trim((string) ($item['label'] ?? $item['url'])), 'url' => $this->normaliseLink((string) $item['url']), 'parent' => trim((string) ($item['parent'] ?? ''))]; $clean[] = ['label' => trim((string) ($item['label'] ?? $item['url'])), 'url' => $this->paths->normaliseLink((string) $item['url']), 'parent' => trim((string) ($item['parent'] ?? ''))];
} }
$menus = $this->store->read('menus'); $this->store->update('menus', function (array $menus) use ($name, $clean) {
$menus[$name] = ['items' => $clean, 'updated' => date(DATE_ATOM)]; $menus[$name] = ['items' => $clean, 'updated' => date(DATE_ATOM)];
$this->store->write('menus', $menus); return $menus;
$html = $this->renderMenu($name, $clean); });
$html = ContentDom::renderMenu($name, $clean);
$updated = $this->propagateMenu($name, $html); $updated = $this->propagateMenu($name, $html);
$this->activity('Updated menu', $name); $this->activity('Updated menu', $name);
$this->respond(['message' => "Menu saved and updated on {$updated} page(s)", 'html' => $html, 'updated_pages' => $updated]); $this->respond(['message' => "Menu saved and updated on {$updated} page(s)", 'html' => $html, 'updated_pages' => $updated]);
@@ -403,7 +507,7 @@ final class CMSController
} }
$url = '/uploads/' . basename($file); $url = '/uploads/' . basename($file);
$items[] = [ $items[] = [
'name' => basename($file), 'url' => $url, 'size' => filesize($file), 'name' => basename($file), 'url' => ($this->config['basePath'] ?? '') . $url, 'size' => filesize($file),
'modified' => date(DATE_ATOM, filemtime($file)), 'alt' => $metadata[basename($file)]['alt'] ?? '', 'modified' => date(DATE_ATOM, filemtime($file)), 'alt' => $metadata[basename($file)]['alt'] ?? '',
'uses' => $usage[$url] ?? 0, 'uses' => $usage[$url] ?? 0,
]; ];
@@ -417,10 +521,13 @@ final class CMSController
{ {
$this->requirePost('upload'); $this->requirePost('upload');
$name = $this->managedMediaName($this->requiredPost('name')); $name = $this->managedMediaName($this->requiredPost('name'));
$metadata = $this->store->read('media');
$alt = is_string($_POST['alt'] ?? null) ? trim($_POST['alt']) : ''; $alt = is_string($_POST['alt'] ?? null) ? trim($_POST['alt']) : '';
$metadata[$name] = ['alt' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $alt) ?? '', 0, 500)]; $entry = ['alt' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $alt) ?? '', 0, 500)];
$this->store->write('media', $metadata); $this->store->update('media', function (array $metadata) use ($name, $entry) {
$metadata[$name] = $entry;
return $metadata;
});
$this->activity('Updated image alt text', $name);
$this->respond(['message' => 'Media details saved']); $this->respond(['message' => 'Media details saved']);
} }
@@ -433,9 +540,10 @@ final class CMSController
if (!is_file($path) || !unlink($path)) { if (!is_file($path) || !unlink($path)) {
throw new \RuntimeException('Unable to delete media'); throw new \RuntimeException('Unable to delete media');
} }
$metadata = $this->store->read('media'); $this->store->update('media', function (array $metadata) use ($name) {
unset($metadata[$name]); unset($metadata[$name]);
$this->store->write('media', $metadata); return $metadata;
});
$this->activity('Deleted media', $name); $this->activity('Deleted media', $name);
$this->respond(['message' => 'Media deleted']); $this->respond(['message' => 'Media deleted']);
} }
@@ -476,19 +584,24 @@ final class CMSController
*/ */
private function publishContent(string $uri, string $content, string $reason): void private function publishContent(string $uri, string $content, string $reason): void
{ {
$path = $this->existingPagePath($uri); $path = $this->paths->existing($uri);
$old = (string) file_get_contents($path); $old = (string) file_get_contents($path);
if ($old !== $content) { if ($old !== $content) {
$this->createRevision($uri, $old, $reason); $this->createRevision($uri, $old, $reason);
} }
// Write beside the destination and rename to avoid serving a partially written document. $this->atomicWrite($path, $content);
$this->captureSharedBlocks($content);
$this->activity($reason, $uri);
}
/** Write beside the destination and rename, so a public page is never served half-written. */
private function atomicWrite(string $path, string $content): void
{
$temporary = $path . '.neo-' . bin2hex(random_bytes(5)); $temporary = $path . '.neo-' . bin2hex(random_bytes(5));
if (file_put_contents($temporary, $content, LOCK_EX) === false || !rename($temporary, $path)) { if (file_put_contents($temporary, $content, LOCK_EX) === false || !rename($temporary, $path)) {
@unlink($temporary); @unlink($temporary);
throw new \RuntimeException('Failed to publish page'); throw new \RuntimeException('Failed to publish page');
} }
$this->captureSharedBlocks($content);
$this->activity($reason, $uri);
} }
/** Save an immutable HTML snapshot and add its searchable metadata to the revision index. */ /** Save an immutable HTML snapshot and add its searchable metadata to the revision index. */
@@ -496,20 +609,31 @@ final class CMSController
{ {
$id = date('YmdHis') . '-' . bin2hex(random_bytes(5)); $id = date('YmdHis') . '-' . bin2hex(random_bytes(5));
$this->store->writePrivateFile('revisions', $id . '.html', $content); $this->store->writePrivateFile('revisions', $id . '.html', $content);
$index = $this->store->read('revisions'); $entry = ['id' => $id, 'uri' => $this->paths->normaliseUri($uri), 'created' => date(DATE_ATOM), 'ts' => microtime(true), 'user' => $this->user(), 'reason' => $reason];
$index[$id] = ['id' => $id, 'uri' => $this->normaliseUri($uri), 'created' => date(DATE_ATOM), 'user' => $this->user(), 'reason' => $reason]; $remove = [];
$this->store->update('revisions', function (array $index) use ($id, $entry, &$remove) {
$index[$id] = $entry;
$remove = $this->pruneRevisions($index); $remove = $this->pruneRevisions($index);
$this->store->write('revisions', $index); return $index;
});
$directory = $this->store->directory('revisions'); $directory = $this->store->directory('revisions');
foreach ($remove as $removeId) { foreach ($remove as $removeId) {
@unlink($directory . basename($removeId) . '.html'); @unlink($directory . basename($removeId) . '.html');
} }
} }
/** Sortable creation time of a revision index entry. */
private static function revisionTime(array $revision): float
{
return (float) ($revision['ts'] ?? strtotime((string) ($revision['created'] ?? '')));
}
/** Apply per-page, global-count, and aggregate-byte retention limits to revisions. */ /** Apply per-page, global-count, and aggregate-byte retention limits to revisions. */
private function pruneRevisions(array &$index): array private function pruneRevisions(array &$index): array
{ {
uasort($index, static fn(array $a, array $b): int => strcmp((string) ($b['created'] ?? ''), (string) ($a['created'] ?? ''))); // Newest first by microsecond 'ts'; 'created' has one-second resolution, so sorting on it alone could prune
// a revision made in the same second as older ones. Entries written before 'ts' existed fall back to 'created'.
uasort($index, static fn(array $a, array $b): int => self::revisionTime($b) <=> self::revisionTime($a));
$perPage = []; $perPage = [];
$kept = 0; $kept = 0;
$bytes = 0; $bytes = 0;
@@ -557,6 +681,12 @@ final class CMSController
*/ */
private function publishDueJobs(): int private function publishDueJobs(): int
{ {
$isDue = static fn(array $job): bool => ($job['status'] ?? 'pending') === 'pending' && strtotime($job['publish_at'] ?? '') <= time();
// Cheap unlocked check first: most requests have nothing due, so they should not contend for the lock.
if (!array_filter($this->store->read('schedules'), $isDue)) {
return 0;
}
$lockPath = $this->store->directory('locks') . 'scheduled.lock'; $lockPath = $this->store->directory('locks') . 'scheduled.lock';
$lock = fopen($lockPath, 'c+'); $lock = fopen($lockPath, 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) { if ($lock === false || !flock($lock, LOCK_EX)) {
@@ -568,13 +698,9 @@ final class CMSController
@chmod($lockPath, 0600); @chmod($lockPath, 0600);
try { try {
$jobs = $this->store->read('schedules'); $published = [];
$changed = false; $failed = [];
$published = 0; foreach (array_filter($this->store->read('schedules'), $isDue) as $id => $job) {
foreach ($jobs as $id => &$job) {
if (($job['status'] ?? 'pending') !== 'pending' || strtotime($job['publish_at'] ?? '') > time()) {
continue;
}
$path = $this->store->directory('scheduled') . basename((string) $id) . '.html'; $path = $this->store->directory('scheduled') . basename((string) $id) . '.html';
try { try {
if (!is_file($path)) { if (!is_file($path)) {
@@ -582,22 +708,27 @@ final class CMSController
} }
$this->publishContent((string) ($job['uri'] ?? ''), (string) file_get_contents($path), 'Scheduled publication'); $this->publishContent((string) ($job['uri'] ?? ''), (string) file_get_contents($path), 'Scheduled publication');
unlink($path); unlink($path);
unset($jobs[$id]); $published[] = $id;
$published++;
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$job['status'] = 'failed'; $failed[] = $id;
$job['failed_at'] = date(DATE_ATOM); $this->activity('Scheduled publication failed', ($job['uri'] ?? '') . ': ' . $exception->getMessage(), (string) ($job['user'] ?? 'scheduler'));
$job['error'] = 'Scheduled publication failed';
$this->logger->write("Scheduled publication {$id} failed: {$exception->getMessage()}", (string) ($job['user'] ?? 'scheduler'));
} }
$changed = true;
} }
unset($job); // Apply outcomes to a fresh read so jobs scheduled or cancelled meanwhile are not lost.
// The index is written once after the loop, avoiding needless churn for several due jobs. $this->store->update('schedules', function (array $jobs) use ($published, $failed) {
if ($changed) { foreach ($published as $id) {
$this->store->write('schedules', $jobs); unset($jobs[$id]);
} }
return $published; foreach ($failed as $id) {
if (isset($jobs[$id])) {
$jobs[$id]['status'] = 'failed';
$jobs[$id]['failed_at'] = date(DATE_ATOM);
$jobs[$id]['error'] = 'Scheduled publication failed';
}
}
return $jobs;
});
return count($published);
} finally { } finally {
flock($lock, LOCK_UN); flock($lock, LOCK_UN);
fclose($lock); fclose($lock);
@@ -607,17 +738,13 @@ final class CMSController
/** Capture shared-region values from a newly published page into the central registry. */ /** Capture shared-region values from a newly published page into the central registry. */
private function captureSharedBlocks(string $html): void private function captureSharedBlocks(string $html): void
{ {
$dom = new \DOMDocument(); $found = [];
@$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); foreach (ContentDom::sharedBlocks($html) as $key => $content) {
$xpath = new \DOMXPath($dom); $found[$key] = ['content' => $content, 'updated' => date(DATE_ATOM), 'user' => $this->user()];
$shared = $this->store->read('shared');
foreach ($xpath->query('//*[@data-neo-shared]') as $node) {
$key = $node->getAttribute('data-neo-shared');
if ($key !== '') {
$shared[$key] = ['content' => $this->innerHtml($node), 'updated' => date(DATE_ATOM), 'user' => $this->user()];
} }
if ($found) {
$this->store->update('shared', fn(array $shared) => array_replace($shared, $found));
} }
$this->store->write('shared', $shared);
} }
/** /**
@@ -630,225 +757,78 @@ final class CMSController
*/ */
private function propagateSharedBlock(string $key, string $content): int private function propagateSharedBlock(string $key, string $content): int
{ {
$count = 0; return $this->rewritePages(
foreach ($this->pageFiles() as $path) { fn(string $html) => ContentDom::withShared($html, $key, $content),
$html = (string) file_get_contents($path); 'Before shared content update'
if (!str_contains($html, 'data-neo-shared')) { );
continue;
}
$dom = new \DOMDocument();
@$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$xpath = new \DOMXPath($dom);
$nodes = $xpath->query('//*[@data-neo-shared="' . $key . '"]');
if ($nodes->length === 0) {
continue;
}
$this->createRevision($this->uriForPath($path), $html, 'Before shared content update');
foreach ($nodes as $node) {
$this->replaceInnerHtml($dom, $node, $content);
}
file_put_contents($path, $dom->saveHTML(), LOCK_EX);
$count++;
}
return $count;
} }
/** Replace matching generated menus across public pages, revisioning each page first. */ /** Replace matching generated menus across public pages, revisioning each page first. */
private function propagateMenu(string $name, string $menuHtml): int private function propagateMenu(string $name, string $menuHtml): int
{
return $this->rewritePages(
fn(string $html) => ContentDom::withMenu($html, $name, $menuHtml),
'Before menu update'
);
}
/**
* Apply a page transform site-wide: revision each changed page, then replace it atomically.
*
* @param callable $transform Receives page HTML and URI; returns new HTML, or null to leave the page alone.
* @param array|null $onlyUris Restrict the pass to these page URIs; null means every page.
* @return int Number of changed pages.
*/
private function rewritePages(callable $transform, string $reason, ?array $onlyUris = null): int
{ {
$count = 0; $count = 0;
foreach ($this->pageFiles() as $path) { // A URI list is resolved one page at a time, so a small batch does not rescan the whole document root.
if ($onlyUris !== null) {
$paths = [];
foreach ($onlyUris as $listed) {
try {
$paths[] = $this->paths->existing($listed);
} catch (PageNotFoundException) {
// A page deleted since it was listed is simply skipped.
}
}
}
foreach ($onlyUris !== null ? $paths : $this->paths->files() as $path) {
$uri = $this->paths->uriFor($path);
$html = (string) file_get_contents($path); $html = (string) file_get_contents($path);
if (!str_contains($html, 'data-neo-menu')) { $changed = $transform($html, $uri);
if ($changed === null) {
continue; continue;
} }
$dom = new \DOMDocument(); $this->createRevision($this->paths->uriFor($path), $html, $reason);
@$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $this->atomicWrite($path, $changed);
$xpath = new \DOMXPath($dom);
$nodes = $xpath->query('//*[@data-neo-menu="' . $name . '"]');
if ($nodes->length === 0) {
continue;
}
// Parse the rendered menu once, then import a fresh deep copy for each destination node.
$menuDom = new \DOMDocument();
@$menuDom->loadHTML($menuHtml, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$sourceNav = $menuDom->getElementsByTagName('nav')->item(0);
if (!$sourceNav) {
continue;
}
$this->createRevision($this->uriForPath($path), $html, 'Before menu update');
foreach (iterator_to_array($nodes) as $node) {
$replacement = $dom->importNode($sourceNav, true);
$node->parentNode->replaceChild($replacement, $node);
}
file_put_contents($path, $dom->saveHTML(), LOCK_EX);
$count++; $count++;
} }
return $count; return $count;
} }
/**
* Replace a DOM node's children with an HTML fragment.
*
* DOMDocument parses forgiving HTML here rather than strict XML, allowing ordinary authoring
* fragments such as <br> without demanding that editors suddenly become XML librarians.
*/
private function replaceInnerHtml(\DOMDocument $dom, \DOMNode $node, string $html): void
{
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$temporary = new \DOMDocument();
@$temporary->loadHTML('<div id="neo-fragment">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$wrapper = $temporary->getElementById('neo-fragment');
if (!$wrapper) {
$node->appendChild($dom->createTextNode($html));
return;
}
foreach (iterator_to_array($wrapper->childNodes) as $child) {
$node->appendChild($dom->importNode($child, true));
}
}
/** Return every public .html or .htm file while excluding the CMS application itself. */
private function pageFiles(): array
{
$files = [];
$scanned = 0;
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->documentRoot, \FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
$scanned++;
if ($scanned > $this->maxScannedEntries) {
throw new \RuntimeException('Document root scan limit exceeded');
}
if (count($files) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit exceeded');
}
if ($file->isLink()) {
continue;
}
$path = $file->getRealPath();
if (!$file->isFile() || $path === false || !str_starts_with($path, $this->documentRoot) || !preg_match('/\.html?$/i', $path)) {
continue;
}
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
continue;
}
$files[] = $path;
}
return $files;
}
/** Resolve an existing public URI to a canonical, in-root HTML file path. */
private function existingPagePath(string $uri): string
{
$uri = $this->normaliseUri($uri);
$candidate = $this->documentRoot . ltrim($uri, '/');
if (is_dir($candidate)) {
$candidate = rtrim($candidate, '/\\') . '/index.html';
} elseif (!preg_match('/\.html?$/i', $candidate)) {
$candidate .= '.html';
}
$real = realpath($candidate);
if (!$real || !str_starts_with($real, $this->documentRoot) || str_starts_with($real, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR) || !preg_match('/\.html?$/i', $real)) {
throw new \RuntimeException('Invalid page path');
}
return $real;
}
/** Build a safe path for a page that may not exist yet. */
private function newPagePath(string $uri): string
{
$path = $this->documentRoot . ltrim($uri, '/');
if (!str_starts_with($path, $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
$this->assertNoSymlinkComponents($path);
$parent = realpath(dirname($path));
if ($parent && !str_starts_with($parent . DIRECTORY_SEPARATOR, $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
throw new \RuntimeException('CMS files cannot be managed as pages');
}
return $path;
}
/** Reject page targets whose existing path components contain symbolic links. */
private function assertNoSymlinkComponents(string $path): void
{
$relative = substr($path, strlen($this->documentRoot));
$current = rtrim($this->documentRoot, DIRECTORY_SEPARATOR);
foreach (explode('/', str_replace('\\', '/', $relative)) as $component) {
if ($component === '') {
continue;
}
$current .= DIRECTORY_SEPARATOR . $component;
if (is_link($current)) {
throw new \RuntimeException('Symbolic links are not permitted in managed page paths');
}
if (file_exists($current)) {
$real = realpath($current);
if ($real === false || !str_starts_with($real . (is_dir($real) ? DIRECTORY_SEPARATOR : ''), $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
}
}
}
/** Canonicalise a request URI and reject traversal or null-byte input. */
private function normaliseUri(string $uri): string
{
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
$path = '/' . ltrim(str_replace('\\', '/', $path), '/');
if (str_contains($path, '..') || str_contains($path, "\0")) {
throw new \RuntimeException('Invalid URI');
}
return $path;
}
/** Canonicalise a new page URI, append .html when absent, and enforce safe characters. */
private function normaliseNewPageUri(string $uri): string
{
$uri = $this->normaliseUri($uri);
if (!preg_match('/\.html?$/i', $uri)) {
$uri .= '.html';
}
if (!preg_match('#^/[a-zA-Z0-9_./-]+\.html?$#', $uri)) {
throw new \RuntimeException('Invalid page name');
}
return $uri;
}
/** Accept an absolute HTTP(S) link or normalise a local site path. */
private function normaliseLink(string $url): string
{
if (preg_match('#^https?://#i', $url)) {
return filter_var($url, FILTER_SANITIZE_URL);
}
return $this->normaliseUri($url);
}
/** Derive the private draft filename from a stable hash of its public URI. */ /** Derive the private draft filename from a stable hash of its public URI. */
private function draftPath(string $uri): string private function draftPath(string $uri): string
{ {
return $this->store->directory('drafts') . hash('sha256', $this->normaliseUri($uri)) . '.html'; return $this->store->directory('drafts') . hash('sha256', $this->paths->normaliseUri($uri)) . '.html';
} }
/** Remove a page's draft HTML and its dashboard metadata entry. */ /** Remove a page's draft HTML and its dashboard metadata entry. */
private function deleteDraft(string $uri): void private function deleteDraft(string $uri): void
{ {
@unlink($this->draftPath($uri)); @unlink($this->draftPath($uri));
$drafts = $this->store->read('drafts'); $draftUri = $this->paths->normaliseUri($uri);
unset($drafts[$this->normaliseUri($uri)]); $this->store->update('drafts', function (array $drafts) use ($draftUri) {
$this->store->write('drafts', $drafts); unset($drafts[$draftUri]);
return $drafts;
});
} }
/** Count all managed upload references in one pass across the public pages. */ /** Count all managed upload references in one pass across the public pages. */
private function mediaUsageCounts(): array private function mediaUsageCounts(): array
{ {
$uses = []; $uses = [];
foreach ($this->pageFiles() as $path) { foreach ($this->paths->files() as $path) {
$html = (string) file_get_contents($path); $html = (string) file_get_contents($path);
if (preg_match_all('#/uploads/[a-f0-9]{32}\.(?:jpg|png|gif|webp)#', $html, $matches)) { if (preg_match_all('#/uploads/[a-f0-9]{32}\.(?:jpg|png|gif|webp)#', $html, $matches)) {
foreach ($matches[0] as $url) { foreach ($matches[0] as $url) {
@@ -875,64 +855,10 @@ final class CMSController
return preg_match('/^[a-f0-9]{32}\.(?:jpg|png|gif|webp)$/', $name) === 1; return preg_match('/^[a-f0-9]{32}\.(?:jpg|png|gif|webp)$/', $name) === 1;
} }
/**
* Render a nested, escaped navigation list from flat parent-labelled items.
* Circular parent references are stopped by the ancestor list rather than pursued forever.
*/
private function renderMenu(string $name, array $items): string
{
$children = [];
foreach ($items as $item) {
$children[$item['parent'] ?? ''][] = $item;
}
$render = function (string $parent, array $ancestors = []) use (&$render, $children): string {
if (empty($children[$parent])) {
return '';
}
$html = '<ul>';
foreach ($children[$parent] as $item) {
$label = htmlspecialchars($item['label'], ENT_QUOTES);
$url = htmlspecialchars($item['url'], ENT_QUOTES);
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
$html .= '<li><a href="' . $url . '">' . $label . '</a>' . $nested . '</li>';
}
return $html . '</ul>';
};
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . $render('') . '</nav>';
}
/** Extract a plain-text document title for the page picker. */ /** Extract a plain-text document title for the page picker. */
private function extractTitle(string $html): string private function extractTitle(string $html): string
{ {
return preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $match) ? trim(strip_tags($match[1])) : 'Untitled'; return preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $match) ? trim(html_entity_decode(strip_tags($match[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : 'Untitled';
}
/** Convert an absolute page path back into its public, slash-separated URI. */
private function uriForPath(string $path): string
{
return '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot)));
}
/** Determine whether a document contains at least one exactly matching editable class. */
private function hasEditableClass(string $html): bool
{
$dom = new \DOMDocument();
@$dom->loadHTML($html);
$xpath = new \DOMXPath($dom);
$class = $this->editableClass;
// Class-token matching avoids treating "editable-extra" as though it were "editable".
$query = '//*[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]';
return $xpath->query($query)->length > 0;
}
/** Serialise only the children of a DOM node, excluding the node's own wrapper tag. */
private function innerHtml(\DOMNode $node): string
{
$html = '';
foreach ($node->childNodes as $child) {
$html .= $node->ownerDocument->saveHTML($child);
}
return $html;
} }
/** Create a new page's parent directories when they do not already exist. */ /** Create a new page's parent directories when they do not already exist. */
@@ -958,7 +884,15 @@ final class CMSController
if (!$this->authentication->isValidCsrfToken($_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null)) { if (!$this->authentication->isValidCsrfToken($_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null)) {
throw new \RuntimeException('Invalid CSRF token'); throw new \RuntimeException('Invalid CSRF token');
} }
if ($capability && !$this->authentication->can($capability)) { if ($capability) {
$this->requireCapability($capability);
}
}
/** Refuse the request unless the current role includes the capability. */
private function requireCapability(string $capability): void
{
if (!$this->authentication->can($capability)) {
throw new \RuntimeException('Your role cannot perform this action'); throw new \RuntimeException('Your role cannot perform this action');
} }
} }
@@ -1007,13 +941,9 @@ final class CMSController
} }
/** Add a bounded dashboard activity entry and mirror it to the audit log. */ /** Add a bounded dashboard activity entry and mirror it to the audit log. */
private function activity(string $action, string $target): void private function activity(string $action, string $target, ?string $user = null): void
{ {
$entries = $this->store->read('activity'); $this->activity->record($user ?? $this->user(), $action, $target);
$entries[] = ['created' => date(DATE_ATOM), 'user' => $this->user(), 'action' => $action, 'target' => $target];
// Retaining the latest 250 entries keeps the dashboard useful without growing forever.
$this->store->write('activity', array_slice($entries, -250));
$this->logger->write($action . ': ' . $target, $this->user());
} }
/** Return the authenticated username used for metadata and audit attribution. */ /** Return the authenticated username used for metadata and audit attribution. */
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace NeoCMS;
/** Pure HTML transformations for shared regions, generated menus, and editable-region detection. */
final class ContentDom
{
/** Return each data-neo-shared region's inner HTML, keyed by its shared name. */
public static function sharedBlocks(string $html): array
{
$found = [];
foreach ((new \DOMXPath(self::load($html)))->query('//*[@data-neo-shared]') as $node) {
$key = $node->getAttribute('data-neo-shared');
if ($key !== '') {
$found[$key] = self::innerHtml($node);
}
}
return $found;
}
/** Replace every region marked data-neo-shared="$key"; null when the page has none. */
public static function withShared(string $html, string $key, string $content): ?string
{
if (!str_contains($html, 'data-neo-shared')) {
return null;
}
$dom = self::load($html);
// $key is restricted to [a-zA-Z0-9_-] by the caller, so it cannot break out of the XPath string.
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-shared="' . $key . '"]');
if ($nodes->length === 0) {
return null;
}
foreach ($nodes as $node) {
self::replaceInnerHtml($dom, $node, $content);
}
return $dom->saveHTML();
}
/** Replace every element marked data-neo-menu="$name" with the rendered <nav>; null when nothing to change. */
public static function withMenu(string $html, string $name, string $menuHtml): ?string
{
if (!str_contains($html, 'data-neo-menu')) {
return null;
}
$dom = self::load($html);
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-menu="' . $name . '"]');
$sourceNav = self::load($menuHtml)->getElementsByTagName('nav')->item(0);
if ($nodes->length === 0 || !$sourceNav) {
return null;
}
foreach (iterator_to_array($nodes) as $node) {
$node->parentNode->replaceChild($dom->importNode($sourceNav, true), $node);
}
return $dom->saveHTML();
}
/**
* Render a nested, escaped navigation list from flat parent-labelled items.
* Circular parent references are stopped by the ancestor list rather than pursued forever.
*/
public static function renderMenu(string $name, array $items): string
{
$children = [];
foreach ($items as $item) {
$children[$item['parent'] ?? ''][] = $item;
}
$render = function (string $parent, array $ancestors = []) use (&$render, $children): string {
if (empty($children[$parent])) {
return '';
}
$html = '<ul>';
foreach ($children[$parent] as $item) {
$label = htmlspecialchars($item['label'], ENT_QUOTES);
$url = htmlspecialchars($item['url'], ENT_QUOTES);
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
$html .= '<li><a href="' . $url . '">' . $label . '</a>' . $nested . '</li>';
}
return $html . '</ul>';
};
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . $render('') . '</nav>';
}
/** Determine whether a document contains at least one element carrying exactly this CSS class token. */
public static function hasClass(string $html, string $class): bool
{
$dom = new \DOMDocument();
@$dom->loadHTML($html);
// Class-token matching avoids treating "editable-extra" as though it were "editable".
$query = '//*[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]';
return (new \DOMXPath($dom))->query($query)->length > 0;
}
/** Parse HTML as UTF-8 (libxml assumes Latin-1 otherwise) without keeping the encoding hint in the output. */
private static function load(string $html): \DOMDocument
{
$dom = new \DOMDocument();
@$dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
foreach (iterator_to_array($dom->childNodes) as $node) {
if ($node->nodeType === XML_PI_NODE) {
$dom->removeChild($node);
}
}
return $dom;
}
/**
* Replace a DOM node's children with an HTML fragment.
*
* DOMDocument parses forgiving HTML here rather than strict XML, allowing ordinary authoring
* fragments such as <br> without demanding that editors suddenly become XML librarians.
*/
private static function replaceInnerHtml(\DOMDocument $dom, \DOMNode $node, string $html): void
{
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$wrapper = self::load('<div id="neo-fragment">' . $html . '</div>')->getElementById('neo-fragment');
if (!$wrapper) {
$node->appendChild($dom->createTextNode($html));
return;
}
foreach (iterator_to_array($wrapper->childNodes) as $child) {
$node->appendChild($dom->importNode($child, true));
}
}
/** Serialise only the children of a DOM node, excluding the node's own wrapper tag. */
private static function innerHtml(\DOMNode $node): string
{
$html = '';
foreach ($node->childNodes as $child) {
$html .= $node->ownerDocument->saveHTML($child);
}
return $html;
}
}
+27 -1
View File
@@ -68,6 +68,32 @@ final class FileStore
@chmod($path, 0600); @chmod($path, 0600);
} }
/**
* Read, modify, and write a JSON document under an exclusive lock so concurrent requests cannot lose updates.
*
* @param callable $fn Receives the current array and returns the array to store.
* @return array The stored document.
*/
public function update(string $name, callable $fn, array $default = []): array
{
$lockPath = $this->directory('locks') . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.lock';
$lock = fopen($lockPath, 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
if (is_resource($lock)) {
fclose($lock);
}
throw new \RuntimeException('Unable to lock CMS data');
}
try {
$value = $fn($this->read($name, $default));
$this->write($name, $value);
return $value;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Atomically write a private content file beneath a managed subdirectory. */ /** Atomically write a private content file beneath a managed subdirectory. */
public function writePrivateFile(string $directory, string $filename, string $content): string public function writePrivateFile(string $directory, string $filename, string $content): string
{ {
@@ -106,7 +132,7 @@ final class FileStore
/** Create a directory recursively or fail with a useful application-level exception. */ /** Create a directory recursively or fail with a useful application-level exception. */
private function ensureDirectory(string $path): void private function ensureDirectory(string $path): void
{ {
if (!is_dir($path) && !mkdir($path, 0700, true) && !is_dir($path)) { if (!is_dir($path) && !@mkdir($path, 0700, true) && !is_dir($path)) {
throw new \RuntimeException('Unable to create CMS data directory'); throw new \RuntimeException('Unable to create CMS data directory');
} }
@chmod($path, 0700); @chmod($path, 0700);
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace NeoCMS;
/** Raised when a URI does not resolve to an existing managed page. */
final class PageNotFoundException extends \RuntimeException
{
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace NeoCMS;
/** Resolves public page URIs to filesystem paths inside the document root, rejecting anything that escapes it. */
final class PagePaths
{
public function __construct(private string $documentRoot, private int $maxManagedPages, private int $maxScannedEntries)
{
}
/** Resolve an existing public URI to a canonical, in-root HTML file path. */
public function existing(string $uri): string
{
$uri = $this->normaliseUri($uri);
$candidate = $this->documentRoot . ltrim($uri, '/');
if (is_dir($candidate)) {
$candidate = rtrim($candidate, '/\\') . '/index.html';
} elseif (!preg_match('/\.html?$/i', $candidate)) {
$candidate .= '.html';
}
$real = realpath($candidate);
if (!$real || !str_starts_with($real, $this->documentRoot) || str_starts_with($real, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR) || !preg_match('/\.html?$/i', $real)) {
throw new PageNotFoundException('Invalid page path');
}
return $real;
}
/** Build a safe path for a page that may not exist yet. */
public function newPath(string $uri): string
{
$path = $this->documentRoot . ltrim($uri, '/');
if (!str_starts_with($path, $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
$this->assertNoSymlinks($path);
$parent = realpath(dirname($path));
if ($parent && !str_starts_with($parent . DIRECTORY_SEPARATOR, $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
throw new \RuntimeException('CMS files cannot be managed as pages');
}
return $path;
}
/** Reject page targets whose existing path components contain symbolic links. */
public function assertNoSymlinks(string $path): void
{
$relative = substr($path, strlen($this->documentRoot));
$current = rtrim($this->documentRoot, DIRECTORY_SEPARATOR);
foreach (explode('/', str_replace('\\', '/', $relative)) as $component) {
if ($component === '') {
continue;
}
$current .= DIRECTORY_SEPARATOR . $component;
if (is_link($current)) {
throw new \RuntimeException('Symbolic links are not permitted in managed page paths');
}
if (file_exists($current)) {
$real = realpath($current);
if ($real === false || !str_starts_with($real . (is_dir($real) ? DIRECTORY_SEPARATOR : ''), $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
}
}
}
/** Canonicalise a new page URI, append .html when absent, and enforce safe characters. */
public function normaliseNewUri(string $uri): string
{
$uri = $this->normaliseUri($uri);
if (!preg_match('/\.html?$/i', $uri)) {
$uri .= '.html';
}
if (!preg_match('#^/[a-zA-Z0-9_./-]+\.html?$#', $uri)) {
throw new \RuntimeException('Invalid page name');
}
return $uri;
}
/** Canonicalise a request URI and reject traversal or null-byte input. */
public function normaliseUri(string $uri): string
{
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
$path = '/' . ltrim(str_replace('\\', '/', $path), '/');
if (str_contains($path, '..') || str_contains($path, "\0")) {
throw new \RuntimeException('Invalid URI');
}
return $path;
}
/** Accept an absolute HTTP(S) link or normalise a local site path. */
public function normaliseLink(string $url): string
{
if (preg_match('#^https?://#i', $url)) {
return filter_var($url, FILTER_SANITIZE_URL);
}
return $this->normaliseUri($url);
}
/** Return every public .html or .htm file while excluding the CMS application itself. */
public function files(): array
{
$files = [];
$scanned = 0;
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->documentRoot, \FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
$scanned++;
if ($scanned > $this->maxScannedEntries) {
throw new \RuntimeException('Document root scan limit exceeded');
}
if (count($files) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit exceeded');
}
if ($file->isLink()) {
continue;
}
$path = $file->getRealPath();
if (!$file->isFile() || $path === false || !str_starts_with($path, $this->documentRoot) || !preg_match('/\.html?$/i', $path)) {
continue;
}
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
continue;
}
$files[] = $path;
}
return $files;
}
/** Convert an absolute page path back into its public, slash-separated URI. */
public function uriFor(string $path): string
{
return '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot)));
}
/**
* Whether a page's local asset reference points at a real file inside the site.
*
* @return bool|null null for external or non-file references (http:, //, data:, #anchor), which are not checked.
*/
public function refExists(string $pagePath, string $ref, string $basePath = ''): ?bool
{
$ref = trim($ref);
if ($ref === '' || preg_match('#^([a-z][a-z0-9+.-]*:|//|\#)#i', $ref)) {
return null;
}
$ref = rawurldecode((string) preg_replace('/[?#].*$/s', '', $ref));
if ($ref === '' || str_contains($ref, "\0")) {
return $ref === '' ? null : false;
}
if ($ref[0] === '/') {
// The upload endpoint writes URLs with the site's URL prefix; strip it to get a path inside the site.
if ($basePath !== '' && str_starts_with($ref, $basePath . '/')) {
$ref = substr($ref, strlen($basePath));
}
$candidate = $this->documentRoot . ltrim($ref, '/');
} else {
$candidate = dirname($pagePath) . DIRECTORY_SEPARATOR . $ref;
}
$real = realpath($candidate);
return $real !== false && str_starts_with($real, $this->documentRoot) && is_file($real);
}
}
+339
View File
@@ -0,0 +1,339 @@
<?php
namespace NeoCMS;
/**
* Finds what NeoCMS could edit in a page (content containers, images, SEO tags) and adds the markers.
*
* Pages are never re-serialised through a DOM parser: a tokenizer locates tag offsets and apply() splices
* text in, so every byte outside the inserted markers is preserved exactly.
*/
final class SiteAnalyser
{
private const VOID = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
private const SKIPPED = ['header', 'nav', 'footer', 'aside', 'form'];
private const SKIPPED_ROLES = ['navigation', 'banner', 'contentinfo'];
private const MIN_SECTION_TEXT = 40;
/** Describe a page: existing and proposed editable regions, images, local references, and SEO tags present. */
public static function analyse(string $html, string $editableClass): array
{
$c = self::scan($html, $editableClass, true);
return [
'title' => $c['title'],
'existingRegions' => $c['existing'],
'proposed' => array_map(fn(int $i) => $c['els'][$i]['name'], $c['regions']),
'images' => array_map(fn(array $i) => ['src' => $i['src'], 'hasAlt' => $i['hasAlt'], 'inRegion' => $i['inRegion'], 'tagged' => $i['tagged']], $c['images']),
'refs' => $c['refs'],
'seo' => $c['seo'],
];
}
/**
* Insert the requested markers into a page.
*
* @param array $options Booleans: content, images, seo.
* @return array|null ['html' => string, 'changes' => string[]], or null when the page needs no change.
*/
public static function apply(string $html, string $editableClass, array $options): ?array
{
$c = self::scan($html, $editableClass, !empty($options['content']));
$edits = [];
$changes = [];
if (!empty($options['content'])) {
foreach ($c['regions'] as $i) {
$edit = self::classEdit($html, $c['els'][$i], $editableClass);
if ($edit) {
$edits[] = $edit;
$changes[] = "Made <{$c['els'][$i]['name']}> editable";
}
}
}
if (!empty($options['images'])) {
foreach ($c['images'] as $img) {
if (!$img['inRegion'] && !$img['tagged'] && $img['taggable']) {
$edits[] = [$img['insertAt'], ' data-neo-image'];
$changes[] = !empty($img['placeholder'])
? 'Made image placeholder editable' . ($img['label'] !== '' ? ': ' . $img['label'] : '')
: 'Made image editable: ' . $img['src'];
}
}
}
if (!empty($options['seo']) && ($seo = self::seoEdit($html, $c))) {
$edits[] = $seo['edit'];
array_push($changes, ...$seo['changes']);
}
if (!$edits) {
return null;
}
usort($edits, fn(array $a, array $b) => $b[0] <=> $a[0]);
foreach ($edits as [$offset, $text]) {
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
}
return ['html' => $html, 'changes' => $changes];
}
/** Tokenise a page and gather everything analyse() and apply() need. */
private static function scan(string $html, string $class, bool $withRegions): array
{
$els = self::elements($html);
$existing = 0;
foreach ($els as $k => $e) {
$els[$k]['editable'] = self::hasClassToken($e['attrs'], $class) || ($e['parent'] >= 0 && $els[$e['parent']]['editable']);
$existing += self::hasClassToken($e['attrs'], $class) ? 1 : 0;
}
$regions = ($withRegions && $existing === 0) ? self::chooseRegions($html, $els) : [];
$inChosen = static function (int $k) use ($els, $regions): bool {
for ($i = $k; $i >= 0; $i = $els[$i]['parent']) {
if (in_array($i, $regions, true)) {
return true;
}
}
return false;
};
// Containers that already hold a real <img> are not placeholders.
$hasImg = [];
foreach ($els as $e) {
for ($p = $e['name'] === 'img' ? $e['parent'] : -1; $p >= 0; $p = $els[$p]['parent']) {
$hasImg[$p] = true;
}
}
$images = [];
$refs = [];
$meta = [];
$title = null;
$h1 = '';
$firstParagraph = '';
foreach ($els as $k => $e) {
$name = $e['name'];
if ($name === 'img') {
$src = self::attr($e['attrs'], 'src');
$images[] = [
'src' => (string) $src,
'hasAlt' => self::attr($e['attrs'], 'alt') !== null,
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
'inRegion' => $e['editable'] || $inChosen($k),
'taggable' => $src !== null && $src !== '' && !str_starts_with(strtolower(trim($src)), 'data:')
&& !(self::attr($e['attrs'], 'width') === '1' && self::attr($e['attrs'], 'height') === '1'),
'insertAt' => $e['start'] + 1 + strlen($name),
'skip' => $e['skip'],
];
if ($src) {
$refs[] = ['type' => 'img', 'url' => $src];
}
} elseif (strtolower((string) self::attr($e['attrs'], 'role')) === 'img' && !in_array($name, ['svg'], true)
&& self::attr($e['attrs'], 'aria-hidden') !== 'true' && empty($hasImg[$k])) {
// A styled box standing in for a photo (for example a "Your photo here" block).
$images[] = [
'src' => '', 'hasAlt' => true, 'placeholder' => true,
'label' => (string) self::attr($e['attrs'], 'aria-label'),
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
'inRegion' => $e['editable'] || $inChosen($k),
'taggable' => true,
'insertAt' => $e['start'] + 1 + strlen($name),
'skip' => $e['skip'],
];
} elseif ($name === 'link' && preg_match('/\bstylesheet\b/i', (string) self::attr($e['attrs'], 'rel')) && ($h = self::attr($e['attrs'], 'href'))) {
$refs[] = ['type' => 'link', 'url' => $h];
} elseif ($name === 'script' && ($s = self::attr($e['attrs'], 'src'))) {
$refs[] = ['type' => 'script', 'url' => $s];
} elseif ($name === 'meta') {
$key = self::attr($e['attrs'], 'name') ?? self::attr($e['attrs'], 'property');
if ($key !== null && trim((string) self::attr($e['attrs'], 'content')) !== '') {
$meta[strtolower($key)] = trim(html_entity_decode((string) self::attr($e['attrs'], 'content'), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
} elseif ($name === 'title' && $title === null) {
$title = self::text($html, $e);
} elseif ($name === 'h1' && $h1 === '') {
$h1 = self::text($html, $e);
} elseif ($name === 'p' && $firstParagraph === '' && !$e['skip'] && strlen($t = self::text($html, $e)) >= 20) {
$firstParagraph = $t;
}
}
$canonical = false;
foreach ($els as $e) {
if ($e['name'] === 'link' && preg_match('/\bcanonical\b/i', (string) self::attr($e['attrs'], 'rel'))) {
$canonical = true;
}
}
return [
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
'seo' => [
'title' => trim((string) $title) !== '', 'description' => isset($meta['description']), 'canonical' => $canonical,
'ogTitle' => isset($meta['og:title']), 'ogDescription' => isset($meta['og:description']),
'ogImage' => isset($meta['og:image']), 'robots' => isset($meta['robots']),
],
];
}
/** Flat list of elements with source offsets, parent index, skipped-subtree and close-position data. */
private static function elements(string $html): array
{
$pattern = '#<!--.*?-->|<(script|style|textarea|template)\b[^>]*>.*?</\1\s*>|<(/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#is';
preg_match_all($pattern, $html, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
$els = [];
$stack = [];
foreach ($tokens as $t) {
// Group 3 is the tag name; comments and raw script/style blocks never set it, but script src still matters.
if (!isset($t[3]) || $t[3][1] < 0) {
if (!empty($t[1][0]) && preg_match('#^<script\b([^>]*)>#i', $t[0][0], $m)) {
$els[] = ['name' => 'script', 'attrs' => $m[1], 'start' => $t[0][1], 'end' => $t[0][1] + strlen($m[0]), 'parent' => $stack ? $stack[count($stack) - 1] : -1, 'close' => $t[0][1] + strlen($t[0][0]), 'skip' => true, 'editable' => false];
}
continue;
}
$name = strtolower($t[3][0]);
if ($t[2][0] === '/') {
// Close the nearest open element of this name and anything implicitly left open inside it.
for ($j = count($stack) - 1; $j >= 0 && $els[$stack[$j]]['name'] !== $name; $j--);
if ($j >= 0) {
for ($x = count($stack) - 1; $x >= $j; $x--) {
$els[$stack[$x]]['close'] = $t[0][1];
}
$stack = array_slice($stack, 0, $j);
}
continue;
}
$parent = $stack ? $stack[count($stack) - 1] : -1;
$attrs = $t[4][0];
$skip = in_array($name, self::SKIPPED, true)
|| in_array(strtolower((string) self::attr($attrs, 'role')), self::SKIPPED_ROLES, true)
|| ($parent >= 0 && $els[$parent]['skip']);
$els[] = ['name' => $name, 'attrs' => $attrs, 'start' => $t[0][1], 'end' => $t[0][1] + strlen($t[0][0]), 'parent' => $parent, 'close' => null, 'skip' => $skip, 'editable' => false];
if (!in_array($name, self::VOID, true) && !str_ends_with(rtrim($attrs), '/')) {
$stack[] = count($els) - 1;
}
}
return $els;
}
/** Pick the containers to make editable: <main>, else top-level articles, else text-bearing sections. */
private static function chooseRegions(string $html, array $els): array
{
$isMain = fn(array $e) => $e['name'] === 'main' || strtolower((string) self::attr($e['attrs'], 'role')) === 'main';
foreach ([$isMain, fn(array $e) => $e['name'] === 'article', fn(array $e) => $e['name'] === 'section' && strlen(self::text($html, $e)) >= self::MIN_SECTION_TEXT] as $match) {
$chosen = [];
foreach ($els as $k => $e) {
if ($e['skip'] || !$match($e)) {
continue;
}
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
if (in_array($p, $chosen, true)) {
continue 2;
}
}
$chosen[] = $k;
}
if ($chosen) {
return $chosen;
}
}
return [];
}
/** Edit that adds the editable class to an element's opening tag, or null when it cannot be done safely. */
private static function classEdit(string $html, array $el, string $class): ?array
{
$tag = substr($html, $el['start'], $el['end'] - $el['start']);
if (preg_match('/\sclass\s*=\s*(["\'])(.*?)\1/is', $tag, $m, PREG_OFFSET_CAPTURE)) {
$closeQuote = $m[0][1] + strlen($m[0][0]) - 1;
return [$el['start'] + $closeQuote, trim($m[2][0]) === '' ? $class : ' ' . $class];
}
if (preg_match('/\sclass\s*=/i', $tag)) {
return null; // Unquoted class value: leave for manual tagging.
}
return [$el['start'] + 1 + strlen($el['name']), ' class="' . $class . '"'];
}
/** One combined insertion of every missing SEO tag just before </head>, with values derived from the page. */
private static function seoEdit(string $html, array $c): ?array
{
if (!preg_match('#^([ \t]*)</head\s*>#im', $html, $m, PREG_OFFSET_CAPTURE)
&& !preg_match('#</head\s*>#i', $html, $m, PREG_OFFSET_CAPTURE)) {
return null;
}
$seo = $c['seo'];
$esc = static fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$trim = static fn(string $s, int $n) => mb_strlen($s) > $n ? rtrim(mb_substr($s, 0, $n - 3)) . '...' : $s;
$valid = static fn(string $s) => $s !== '' && mb_check_encoding($s, 'UTF-8');
$title = trim($c['title']) !== '' ? trim($c['title']) : $c['h1'];
// Prefer the page's own description over one derived from its first paragraph.
$description = (string) ($c['metaText']['description'] ?? '') !== '' ? $c['metaText']['description'] : ($valid($c['paragraph']) ? $trim($c['paragraph'], 155) : '');
$image = '';
// A content image makes a better social preview than a header logo.
foreach ([false, true] as $allowSkipped) {
foreach ($c['images'] as $img) {
// Open Graph needs an absolute or root-relative address; a page-relative one would be wrong.
if ($img['taggable'] && preg_match('#^(/|https?://)#i', $img['src']) && ($allowSkipped || !$img['skip'])) {
$image = $img['src'];
break 2;
}
}
}
$tags = [];
$changes = [];
if (!$seo['title'] && $valid($c['h1'])) {
$tags[] = '<title>' . $esc($c['h1']) . '</title>';
$changes[] = 'Added page title';
}
if (!$seo['description'] && $description !== '') {
$tags[] = '<meta name="description" content="' . $esc($description) . '">';
$changes[] = 'Added meta description';
}
if (!$seo['ogTitle'] && $valid($title)) {
$tags[] = '<meta property="og:title" content="' . $esc($title) . '">';
$changes[] = 'Added og:title';
}
if (!$seo['ogDescription'] && $description !== '') {
$tags[] = '<meta property="og:description" content="' . $esc($description) . '">';
$changes[] = 'Added og:description';
}
if (!$seo['ogImage'] && $image !== '') {
$tags[] = '<meta property="og:image" content="' . $esc($image) . '">';
$changes[] = 'Added og:image';
}
if (!$tags) {
return null;
}
$atLineStart = isset($m[1]) && $m[1][0] !== null && $m[0][1] === $m[1][1];
$offset = $m[0][1];
// Match the page's own line endings so CRLF files do not end up with mixed endings.
$nl = str_contains($html, "\r\n") ? "\r\n" : "\n";
$body = ' ' . implode($nl . ' ', $tags) . $nl;
$text = $atLineStart ? $body : $nl . $body;
return ['edit' => [$offset, $text], 'changes' => $changes];
}
/** Visible text of an element (tags stripped, entities decoded, whitespace collapsed). */
private static function text(string $html, array $el): string
{
$end = $el['close'] ?? $el['end'];
$inner = substr($html, $el['end'], max(0, $end - $el['end']));
return trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '');
}
private static function hasClassToken(string $attrs, string $class): bool
{
return in_array($class, preg_split('/\s+/', (string) self::attr($attrs, 'class')) ?: [], true);
}
/** Return an attribute's value from a tag's attribute string, '' for a bare attribute, null when absent. */
private static function attr(string $attrs, string $name): ?string
{
if (!preg_match('/(?:^|\s)' . preg_quote($name, '/') . '(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?(?=\s|\/|$)/i', $attrs, $m)) {
return null;
}
return (string) ($m[1] ?? '') . (string) ($m[2] ?? '') . (string) ($m[3] ?? '');
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
<section> <section>
<h2>Edit and publish</h2> <h2>Edit and publish</h2>
<p>Open <strong>Pages</strong>, choose a page, and select any region carrying the configured editable class.</p> <p>Open <strong>Pages</strong>, choose a page, and select any region carrying the configured editable class.</p>
<p><strong>Save Draft</strong> keeps work private. <strong>Publish</strong> creates a revision before replacing the public page.</p> <p>The editor offers <strong>Save Draft</strong> (private), <strong>Schedule Publish</strong>, and <strong>Publish Now</strong>, which creates a revision before replacing the public page.</p>
</section> </section>
<!-- Content creation and rollback tools. --> <!-- Content creation and rollback tools. -->