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>
155 lines
4.1 KiB
PHP
155 lines
4.1 KiB
PHP
<?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);
|
|
}
|
|
}
|