Add Users, media library, navigation menus from the scan, new-page wizard and deleted-page recovery

Users (Tools > Users)
- Everyone can update their display name, email and password; administrators can add,
  edit, invite (one-time link), block and delete accounts.
- Accounts in config.local.php stay locked to config (the main administrator); CMS
  accounts live in data/users.json. Sign-in is by login name.
- Roles are now editor and administrator (publisher is an alias of editor).
- Account protection: password hashes only (12-72 characters), administrator password
  confirmation for sensitive actions, rate-limited failures, hashed single-use invite
  tokens, self-lockout guards, sessions of blocked/deleted users end on the next request,
  every account event audited without secrets.
- dataDirectory can move data outside the web root; the dashboard recommends it.

Media library
- Upload images, documents, video and audio, sorted into four categories by extension.
- Extension allow-list; executables, scripts, HTML/SVG, archives, legacy and macro Office
  files refused; content sniffing; files containing code refused; PDFs with scripts refused;
  Office/OpenDocument packages inspected for macros and embedded objects.
- Media library button in the editor inserts an image, document link, video or audio.

Navigation menus, pages and interface
- The site scan detects primary/secondary navigation and seeds Navigation Menus; menus can be
  renamed, edited and deleted but not created by the CMS; a scan button finds new navigation.
- New page wizard (template, name, navigation group) in the Pages dialogue; new pages stay
  private drafts until published; pages can be used as templates.
- Deleted pages can be recovered from the Dashboard.
- Full-width top bar with the user at the top right, device icons, consistent buttons.

Tests and docs updated (tests/run.php, README, ARCHITECTURE, DEVELOPMENT, CHANGELOG).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Geoff Moore
2026-09-24 17:47:34 +01:00
co-authored by Claude Sonnet 5
parent 89d64418de
commit 8946915bfe
20 changed files with 3001 additions and 325 deletions
+93 -4
View File
@@ -16,7 +16,7 @@ final class SiteAnalyser
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
public static function analyse(string $html, string $editableClass, bool $allNavs = false): array
{
$c = self::scan($html, $editableClass, true);
return [
@@ -26,14 +26,24 @@ final class SiteAnalyser
'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'],
'navs' => array_values(array_map(
fn(array $n) => ['menu' => $n['menu'] ?? ($allNavs ? $n['name'] : $n['slot']), 'links' => count($n['links']), 'tagged' => $n['menu'] !== null, 'sig' => md5(implode("\n", array_column($n['links'], 'url')))],
array_filter($c['navs'], fn(array $n) => $n['menu'] !== null || ($allNavs ? $n['name'] : $n['slot']) !== null)
)),
];
}
/** Every navigation block in a page with its byte offsets, links and any data-neo-menu name (used to replace menus in place). */
public static function navs(string $html): array
{
return self::navList($html, self::elements($html));
}
/**
* 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.
* @param array $options Booleans: content, images, seo, menus; allNavs also names navs that are not clearly header or footer menus.
* @return array|null ['html' => string, 'changes' => string[], 'menus' => [name => links]], or null when the page needs no change.
*/
public static function apply(string $html, string $editableClass, array $options): ?array
{
@@ -64,6 +74,19 @@ final class SiteAnalyser
$edits[] = $seo['edit'];
array_push($changes, ...$seo['changes']);
}
$menus = [];
if (!empty($options['menus'])) {
$named = array_filter(array_column($c['navs'], 'menu'));
foreach ($c['navs'] as $nav) {
$name = !empty($options['allNavs']) ? $nav['name'] : $nav['slot'];
// One nav per name per page, and never over a menu the page already marks by hand.
if ($nav['menu'] === null && $name !== null && !isset($menus[$name]) && !in_array($name, $named, true)) {
$edits[] = [$nav['insertAt'], ' data-neo-menu="' . $name . '"'];
$changes[] = 'Marked ' . $name . ' navigation as a menu (' . count($nav['links']) . ' links)';
$menus[$name] = $nav['links'];
}
}
}
if (!$edits) {
return null;
}
@@ -72,7 +95,72 @@ final class SiteAnalyser
foreach ($edits as [$offset, $text]) {
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
}
return ['html' => $html, 'changes' => $changes];
return ['html' => $html, 'changes' => $changes, 'menus' => $menus];
}
/** Navigation blocks: <nav> or role=navigation, classified as the primary (header) or secondary (footer) menu when recognisable. */
private static function navList(string $html, array $els): array
{
$navs = [];
foreach ($els as $k => $e) {
if ($e['name'] !== 'nav' && strtolower((string) self::attr($e['attrs'], 'role')) !== 'navigation') {
continue;
}
$links = [];
foreach ($els as $a) {
if ($a['name'] !== 'a' || ($href = self::attr($a['attrs'], 'href')) === null) {
continue;
}
$inside = false;
$lis = [];
for ($p = $a['parent']; $p >= 0; $p = $els[$p]['parent']) {
if ($p === $k) {
$inside = true;
break;
}
if ($els[$p]['name'] === 'li') {
$lis[] = $p;
}
}
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$label = self::text($html, $a) ?: trim((string) self::attr($a['attrs'], 'aria-label'));
if (!$inside || $href === '' || $label === '' || stripos($href, 'javascript:') === 0) {
continue;
}
// A link inside a nested list is a child of the link that owns the enclosing list item.
$parent = '';
if (isset($lis[1])) {
foreach ($els as $b) {
if ($b['name'] === 'a' && $b['parent'] === $lis[1]) {
$parent = self::text($html, $b);
break;
}
}
}
$links[] = ['label' => $label, 'url' => $href, 'parent' => $parent];
}
$ancestors = [];
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
$ancestors[] = $els[$p]['name'] . ':' . strtolower((string) self::attr($els[$p]['attrs'], 'role'));
}
$in = fn(string $tag, string $role) => in_array($tag . ':', $ancestors, true) || in_array(':' . $role, $ancestors, true) || in_array($tag . ':' . $role, $ancestors, true);
$label = strtolower(self::attr($e['attrs'], 'aria-label') . ' ' . self::attr($e['attrs'], 'class') . ' ' . self::attr($e['attrs'], 'id'));
$slot = null;
if ($links) {
if ($in('footer', 'contentinfo') || preg_match('/\b(footer|secondary|legal)\b/', $label)) {
$slot = 'secondary';
} elseif ($in('header', 'banner') || preg_match('/\b(main|primary|site-nav|top)\b/', $label)) {
$slot = 'primary';
}
}
$navs[] = [
'menu' => ($m = self::attr($e['attrs'], 'data-neo-menu')) !== null && $m !== '' ? $m : null,
'slot' => $slot, 'name' => $slot ?? ($links ? (trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(substr((string) (self::attr($e['attrs'], 'aria-label') ?: self::attr($e['attrs'], 'id')), 0, 40))), '-') ?: 'navigation') : null), 'links' => $links, 'attrs' => $e['attrs'],
'end' => $e['end'], 'close' => $e['close'], 'insertAt' => $e['start'] + 1 + strlen($e['name']),
];
}
return $navs;
}
/** Tokenise a page and gather everything analyse() and apply() need. */
@@ -163,6 +251,7 @@ final class SiteAnalyser
}
return [
'navs' => self::navList($html, $els),
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
'seo' => [