diff --git a/src/App.tsx b/src/App.tsx index 564ee1a3d..09545b5db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,76 @@ import { WebSocketProvider } from './contexts/WebSocketContext'; import AppContent from './components/app/AppContent'; import i18n from './i18n/config.js'; +/** + * Detect the router basename from explicit runtime config or deployment hints. + */ +function detectRouterBasename() { + const explicitBasename = typeof window !== 'undefined' ? window.__ROUTER_BASENAME__ || '' : ''; + if (explicitBasename) { + return explicitBasename.replace(/\/+$/, ''); + } + + if (typeof window === 'undefined' || typeof document === 'undefined') { + return ''; + } + + const candidatePaths = [ + { kind: 'manifest' as const, value: document.querySelector('link[rel="manifest"]')?.getAttribute('href') }, + { kind: 'script' as const, value: document.querySelector('script[type="module"][src]')?.getAttribute('src') }, + ...Array.from( + document.querySelectorAll( + 'link[rel~="icon"][href], link[rel="apple-touch-icon"][href], link[rel="apple-touch-icon-precomposed"][href], link[rel="mask-icon"][href]' + ) + ).map((node) => ({ + kind: 'icon' as const, + value: node.getAttribute('href'), + })), + ].filter((candidate): candidate is { kind: 'manifest' | 'script' | 'icon'; value: string } => Boolean(candidate.value)); + + let detectedBasename = ''; + for (const candidate of candidatePaths) { + try { + const candidateUrl = new URL(candidate.value, document.baseURI || window.location.href); + if (candidateUrl.origin !== window.location.origin) { + continue; + } + + const pathname = candidateUrl.pathname; + const normalizedPathname = pathname.replace(/\/+$/, ''); + + let normalized = ''; + if (candidate.kind === 'script') { + const match = normalizedPathname.match(/^(.*)\/assets\//); + normalized = match?.[1] ? match[1].replace(/\/+$/, '') : ''; + } else { + const manifestMatch = normalizedPathname.match(/^(.*)\/(?:manifest\.json|site\.webmanifest)$/); + const iconMatch = normalizedPathname.match( + /^(.*)\/(?:favicon(?:\.[^/]+)?|apple-touch-icon(?:-[^/]+)?(?:\.[^/]+)?|mask-icon(?:\.[^/]+)?|[^/]*icon[^/]*)$/ + ); + const match = candidate.kind === 'manifest' ? manifestMatch : iconMatch; + if (match?.[1]) { + const segments = match[1].split('/').filter(Boolean); + while (segments.length > 1 && ['assets', 'static', 'icons', 'images'].includes(segments[segments.length - 1])) { + segments.pop(); + } + normalized = segments.length > 0 ? `/${segments.join('/')}` : ''; + } + } + + if (normalized.length > detectedBasename.length) { + detectedBasename = normalized; + } + } catch { + // Ignore invalid candidate URLs and continue checking other hints. + } + } + + return detectedBasename; +} + export default function App() { + const routerBasename = detectRouterBasename(); + return ( @@ -17,7 +86,7 @@ export default function App() { - + } /> } />