From d3f196570d51b7202ce684dfbaec3dd087363a8c Mon Sep 17 00:00:00 2001 From: Geoff Moore Date: Thu, 24 Sep 2026 13:17:09 +0100 Subject: [PATCH] Add site scan, editor-based publishing workflow and subfolder support Adds a whole-site "Site scan" that analyses pages, images and SEO tags and makes pages editable in one step, moves Save Draft / Schedule Publish / Publish Now into the content editor, supports installs in a subfolder, and hardens metadata handling. Only the cms/ directory is changed. Added - Site scan (Tools > Site scan, administrators): reports pages, folders, editable/untagged pages, images without alt text, broken local references, unused uploads and missing title/description/Open Graph tags; then tags
or top-level article/section content areas, images outside editable regions and photo placeholders, and inserts only missing SEO tags. Page source is edited byte-for-byte (no DOM re-serialisation), a revision is kept for every changed page, later scans list only pages that still need changes, and work runs in small batches with a progress bar and "N of M pages" count. New administrator-only actions: listSitePages, analyseSitePages, applySiteTagging. - Click-to-edit images and photo placeholders (role="img" boxes) with a Media Library picker and alt text. - Subfolder installs (basePath / siteRoot auto-detected); root installs behave as before. - Audit/activity entries for cancelled schedules, image uploads, alt-text changes and failed scheduled publications (shared Activity class). - Snackbar (success/error) and table-style dialogs for the page picker, revision history, dashboard, shared content, menus and site scan. Changed - Editor actions are now Save Draft | Schedule Publish | Publish Now | Cancel; saving in the editor and applying image/SEO changes stores a private draft. The toolbar no longer has Save Draft, Schedule or Publish. - CMSController split into PagePaths, ContentDom and Activity; entry points share cms/bootstrap.php. Restoring a deleted page uses PageNotFoundException instead of matching an error message. Fixed - Lost updates on JSON metadata (locked FileStore::update). - Non-atomic site-wide shared-content/menu writes; non-ASCII text handling. - Scheduled publishing contending for a lock on every request. - Revision pruning dropping the newest revision (revisions gain a "ts"). - Stale cached pages in the editor preview after a site is replaced. - Saved drafts reloading without stylesheets or the correct page path, and Publish discarding a draft that was not re-offered in the same session. - HTML entities in page titles; backslashes in folder names on Windows. Migration: no manual steps. basePath/siteRoot are optional and detected automatically. The analyseSite action was replaced by listSitePages and analyseSitePages. Co-Authored-By: Claude Sonnet 5 --- cms/bootstrap.php | 16 + cms/controller/index.php | 8 +- cms/css/editor.css | 183 +++++-- cms/image_upload.php | 19 +- cms/index.php | 86 ++- cms/js/cms.js | 556 +++++++++++++++++--- cms/login/index.php | 14 +- cms/logs/index.php | 2 +- cms/publish_scheduled.php | 10 +- cms/src/NeoCMS/Activity.php | 22 + cms/src/NeoCMS/Authentication.php | 6 +- cms/src/NeoCMS/CMSController.php | 634 ++++++++++------------- cms/src/NeoCMS/ContentDom.php | 136 +++++ cms/src/NeoCMS/FileStore.php | 28 +- cms/src/NeoCMS/PageNotFoundException.php | 8 + cms/src/NeoCMS/PagePaths.php | 163 ++++++ cms/src/NeoCMS/SiteAnalyser.php | 339 ++++++++++++ cms/welcome.html | 2 +- 18 files changed, 1706 insertions(+), 526 deletions(-) create mode 100644 cms/bootstrap.php create mode 100644 cms/src/NeoCMS/Activity.php create mode 100644 cms/src/NeoCMS/ContentDom.php create mode 100644 cms/src/NeoCMS/PageNotFoundException.php create mode 100644 cms/src/NeoCMS/PagePaths.php create mode 100644 cms/src/NeoCMS/SiteAnalyser.php diff --git a/cms/bootstrap.php b/cms/bootstrap.php new file mode 100644 index 0000000..eeaa439 --- /dev/null +++ b/cms/bootstrap.php @@ -0,0 +1,16 @@ + 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; } diff --git a/cms/image_upload.php b/cms/image_upload.php index 4d8a669..a88e556 100644 --- a/cms/image_upload.php +++ b/cms/image_upload.php @@ -7,16 +7,12 @@ */ // Load credentials, role assignments, and shared application settings. -require_once "config.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"; -}); +require_once __DIR__ . '/bootstrap.php'; +use NeoCMS\Activity; use NeoCMS\Authentication; use NeoCMS\FileStore; +use NeoCMS\Logger; use NeoCMS\SecurityHeaders; 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); // 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'); $originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME); $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); 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]); } else { flock($uploadLock, LOCK_UN); diff --git a/cms/index.php b/cms/index.php index c437f03..5c3157e 100644 --- a/cms/index.php +++ b/cms/index.php @@ -2,13 +2,7 @@ /** Server-rendered shell for the authenticated NeoCMS administration application. */ // Load configuration before initialising sessions and role-aware controls. -require_once "config.php"; - -// Resolve project classes without requiring Composer. -spl_autoload_register(function ($class) { - $classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class); - require_once "./src/{$classPath}.php"; -}); +require_once __DIR__ . '/bootstrap.php'; use NeoCMS\Authentication; 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. if (!$authentication->isLoggedIn()) { - header("Location: /cms/login/"); + header("Location: " . $config['basePath'] . "/cms/login/"); exit; } SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null); @@ -35,16 +29,21 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) NeoCMS + - + - + - +
@@ -62,9 +61,6 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
- - - @@ -81,15 +77,17 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) ?>
- - +
- - + + + +
@@ -97,6 +95,7 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
+

Choose a template

@@ -111,10 +110,18 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
- -
    - -
+ +
+ + + + + + + +
PageTitleLast modifiedActions
+ +
@@ -128,6 +135,7 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) +
@@ -165,25 +173,53 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
+
-
- + +
+ +
+
+ Changes to make + + + +
+
+

Each changed page keeps a revision, so this can be undone from Revision history.

+ +
+ + +
+
+
+ + + +
+
+ + diff --git a/cms/js/cms.js b/cms/js/cms.js index d79aba6..6574c7d 100644 --- a/cms/js/cms.js +++ b/cms/js/cms.js @@ -11,6 +11,7 @@ // References to the active iframe document and currently selected editable element. let iframeDoc = null; let currentElement = null; + let currentImage = null; // Dirty state protects unpublished browser changes from accidental navigation. let dirty = false; @@ -21,10 +22,27 @@ // Remember offered drafts so an iframe rewrite does not prompt for the same draft twice. 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. const csrfToken = $('meta[name="csrf-token"]').attr('content') || ''; const editableClass = $('meta[name="neo-editable-class"]').attr('content') || 'editable'; const editableSelector = '.' + editableClass; + // A styled box standing in for a photo: role="img" on something other than an or . + 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. $(function () { @@ -51,7 +69,7 @@ if ((method || 'GET') === 'POST') { 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) { const response = xhr.responseJSON || {}; throw new Error(response.error || 'The CMS request failed'); @@ -64,6 +82,8 @@ $('.cms-dialog, #newPageDialog, #fileListDialog').not('#editModal').each(function () { $(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({ autoOpen: false, modal: true, @@ -78,9 +98,6 @@ $('#dashboardButton').on('click', function () { loadDashboard(true); }); $('#newPage').on('click', openNewPage); $('#selectPage').on('click', openPages); - $('#saveDraft').on('click', saveDraft); - $('#savePage').on('click', publishPage); - $('#scheduleButton').on('click', openSchedule); $('#mediaButton').on('click', openMedia); $('#seoButton').on('click', openSeo); $('#moreButton').on('click', function () { $('#toolsDialog').dialog('open'); }); @@ -88,6 +105,9 @@ $('#accessibilityButton').on('click', runAccessibilityCheck); $('#sharedButton').on('click', openShared); $('#menusButton').on('click', openMenus); + $('#siteScanButton').on('click', openSiteScan); + $('#siteScanApply').on('click', applySiteScan); + $('#imageForm').on('submit', applyImage); $('#seoForm').on('submit', applySeo); $('#scheduleForm').on('submit', schedulePage); $('#sharedForm').on('submit', saveShared); @@ -107,6 +127,22 @@ */ function initialiseFrame() { 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; currentElement = null; @@ -116,7 +152,12 @@ if ($(event.target).closest('.button-container').length) return; event.preventDefault(); 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. $(iframeDoc).on('click.neocms', 'a', function (event) { @@ -126,11 +167,29 @@ }); $(iframeDoc).on('click.neocms', '.duplicate-before, .duplicate-after', duplicateBlock); $(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
  • ').attr('data-search', (page.name + ' ' + page.title).toLowerCase()); - $('