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>
186 lines
7.8 KiB
PHP
186 lines
7.8 KiB
PHP
<?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 the list inside every element marked data-neo-menu="$name", keeping the element's own attributes
|
|
* and every other byte of the page. Null when nothing needs to change.
|
|
*/
|
|
public static function withMenu(string $html, string $name, array $items, string $uri = '', string $basePath = ''): ?string
|
|
{
|
|
if (!str_contains($html, 'data-neo-menu')) {
|
|
return null;
|
|
}
|
|
$list = self::menuList($items, $uri, $basePath);
|
|
$flat = fn(string $s) => preg_replace('/>\s+</', '><', trim($s));
|
|
$edits = [];
|
|
foreach (SiteAnalyser::navs($html) as $nav) {
|
|
if ($nav['menu'] !== $name || $nav['close'] === null) {
|
|
continue;
|
|
}
|
|
if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) {
|
|
$edits[] = [$nav['end'], $nav['close'] - $nav['end']];
|
|
}
|
|
}
|
|
rsort($edits);
|
|
foreach ($edits as [$offset, $length]) {
|
|
$html = substr($html, 0, $offset) . $list . substr($html, $offset + $length);
|
|
}
|
|
return $edits ? $html : null;
|
|
}
|
|
|
|
/** A menu as a standalone <nav> (the list itself comes from menuList). */
|
|
public static function renderMenu(string $name, array $items): string
|
|
{
|
|
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . self::menuList($items) . '</nav>';
|
|
}
|
|
|
|
/**
|
|
* Render a nested, escaped list from flat parent-labelled items, marking the link to the page being written.
|
|
* Circular parent references are stopped by the ancestor list rather than pursued forever.
|
|
*/
|
|
public static function menuList(array $items, string $uri = '', string $basePath = ''): string
|
|
{
|
|
$children = [];
|
|
foreach ($items as $item) {
|
|
$children[$item['parent'] ?? ''][] = $item;
|
|
}
|
|
$render = function (string $parent, array $ancestors = []) use (&$render, $children, $uri, $basePath): 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);
|
|
$current = $uri !== '' && self::isPage($item['url'], $uri, $basePath) ? ' aria-current="page"' : '';
|
|
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
|
|
$html .= '<li><a href="' . $url . '"' . $current . '>' . $label . '</a>' . $nested . '</li>';
|
|
}
|
|
return $html . '</ul>';
|
|
};
|
|
return $render('');
|
|
}
|
|
|
|
/** Turn a page link into a site path (relative links resolved against the page, query/fragment kept); null for external or fragment-only links. */
|
|
public static function resolveLink(string $url, string $pageUri): ?string
|
|
{
|
|
$url = trim($url);
|
|
$path = parse_url($url, PHP_URL_PATH);
|
|
if ($url === '' || $url[0] === '#' || preg_match('#^([a-z][a-z0-9+.-]*:|//)#i', $url) || !is_string($path) || $path === '') {
|
|
return null;
|
|
}
|
|
if ($path[0] !== '/') {
|
|
$path = rtrim(str_replace('\\', '/', dirname($pageUri)), '/') . '/' . $path;
|
|
}
|
|
$out = [];
|
|
foreach (explode('/', $path) as $segment) {
|
|
if ($segment === '' || $segment === '.') {
|
|
continue;
|
|
}
|
|
$segment === '..' ? array_pop($out) : $out[] = $segment;
|
|
}
|
|
return '/' . implode('/', $out) . (str_ends_with($path, '/') && $out ? '/' : '') . substr($url, strcspn($url, '?#'));
|
|
}
|
|
|
|
/** Whether a menu link points at the page with this URI (a folder link means its index.html; a subfolder install's prefix is ignored). */
|
|
private static function isPage(string $url, string $uri, string $basePath): bool
|
|
{
|
|
$resolved = self::resolveLink($url, $uri);
|
|
if ($resolved === null) {
|
|
return false;
|
|
}
|
|
$index = fn(string $p) => str_ends_with($p, '/') ? $p . 'index.html' : $p;
|
|
$path = $index((string) parse_url($resolved, PHP_URL_PATH));
|
|
$uri = $index($uri);
|
|
return $path === $uri || ($basePath !== '' && $path === $basePath . $uri);
|
|
}
|
|
/** 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;
|
|
}
|
|
}
|