1
0

feat: extract bookmarks as standalone module with MintyPHP\Module\Bookmarks namespace

Moves bookmarks from core hardcoding into modules/bookmarks/ as a
fully self-contained module, following the same pattern as addressbook.

Module contributions via platform slots:
- aside.tab_panel: bookmark sidebar panel
- topbar.right_item: bookmark toggle button
- layout.body_end_template: bookmark/group dialogs
- layout.head_style: bookmark CSS (form + sidebar)
- runtime.component: bookmark-save and bookmark-panel (phase: late)
- search.resource_item: bookmarks in global search (user-scoped via {{userId}})

Backend fully in module namespace (MintyPHP\Module\Bookmarks\*):
- Service: BookmarkService, BookmarkServicesFactory, BookmarkRepositoryFactory
- Repository: BookmarkRepository, BookmarkGroupRepository, BookmarkNavigationRepository
- Support: BookmarkUrlNormalizer
- Providers: BookmarksSessionProvider, BookmarksLayoutProvider, BookmarksSearchProvider

Core cleanup:
- Removed all bookmark-specific markup from core templates
- Removed core DI registrations for bookmark services
- Removed core bookmark pages, JS, CSS
- AuthSessionTenantContextService delegates to module SessionProvider

Architecture guards:
- NoBookmarksHardcodingTest: verifies zero bookmark references in core
- testModuleClassesUseModuleNamespace: prevents core namespace leakage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:20:20 +01:00
parent c7b8fd516a
commit 4871c6032e
37 changed files with 1514 additions and 294 deletions

View File

@@ -0,0 +1,154 @@
<?php
namespace MintyPHP\Module\Bookmarks\Support;
final class BookmarkUrlNormalizer
{
public static function canonicalizeRelative(string $url): string
{
$url = trim($url);
if ($url === '' || str_contains($url, "\x00")) {
return '';
}
// Strip fragments: they are client-side only and should not
// differentiate bookmarks during deduplication.
$hashPos = strpos($url, '#');
if ($hashPos !== false) {
$url = substr($url, 0, $hashPos);
}
if ($url === '') {
return '';
}
if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $url) || str_starts_with($url, '//')) {
return '';
}
$parts = parse_url($url);
if ($parts === false) {
return '';
}
$path = self::normalizePath((string) ($parts['path'] ?? ''));
$query = self::normalizeQuery((string) ($parts['query'] ?? ''));
if ($path === '' && $query === '') {
return '';
}
if ($query === '') {
return $path;
}
return $path !== '' ? $path . '?' . $query : '?' . $query;
}
public static function canonicalizeRequestUri(string $requestUri, string $localeBase): string
{
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?? '');
$requestQuery = (string) (parse_url($requestUri, PHP_URL_QUERY) ?? '');
$basePath = (string) (parse_url($localeBase, PHP_URL_PATH) ?? '');
$basePath = '/' . trim($basePath, '/');
if ($basePath !== '/' && ($requestPath === $basePath || str_starts_with($requestPath, $basePath . '/'))) {
$requestPath = substr($requestPath, strlen($basePath));
}
$relative = ltrim($requestPath, '/');
if ($requestQuery !== '') {
$relative .= '?' . $requestQuery;
}
return self::canonicalizeRelative($relative);
}
private static function normalizePath(string $path): string
{
$path = ltrim(trim($path), '/');
if ($path === '') {
return '';
}
$collapsed = preg_replace('#/+#', '/', $path);
if ($collapsed === null) {
return '';
}
$segments = explode('/', $collapsed);
$normalized = [];
$depth = 0;
foreach ($segments as $segment) {
$segment = trim($segment);
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
if ($depth <= 0) {
return '';
}
array_pop($normalized);
$depth--;
continue;
}
$normalized[] = $segment;
$depth++;
}
return implode('/', $normalized);
}
private static function normalizeQuery(string $query): string
{
$query = trim($query);
if ($query === '') {
return '';
}
$parsed = [];
foreach (explode('&', $query) as $pair) {
if ($pair === '') {
continue;
}
$separatorPos = strpos($pair, '=');
if ($separatorPos === false) {
$rawKey = $pair;
$rawValue = '';
} else {
$rawKey = substr($pair, 0, $separatorPos);
$rawValue = substr($pair, $separatorPos + 1);
}
$key = rawurldecode(str_replace('+', ' ', $rawKey));
$value = rawurldecode(str_replace('+', ' ', $rawValue));
if ($key === '') {
continue;
}
$parsed[$key][] = $value;
}
if ($parsed === []) {
return '';
}
ksort($parsed, SORT_STRING);
$normalizedPairs = [];
foreach ($parsed as $key => $values) {
sort($values, SORT_STRING);
foreach ($values as $value) {
$normalizedPairs[] = rawurlencode($key) . '=' . rawurlencode((string) $value);
}
}
return implode('&', $normalizedPairs);
}
}