Implement JQHTML function cache ID system and fix bundle compilation Implement underscore prefix for system tables Fix JS syntax linter to support decorators and grant exception to Task system SPA: Update planning docs and wishlists with remaining features SPA: Document Navigation API abandonment and future enhancements Implement SPA browser integration with History API (Phase 1) Convert contacts view page to SPA action Convert clients pages to SPA actions and document conversion procedure SPA: Merge GET parameters and update documentation Implement SPA route URL generation in JavaScript and PHP Implement SPA bootstrap controller architecture Add SPA routing manual page (rsx:man spa) Add SPA routing documentation to CLAUDE.md Phase 4 Complete: Client-side SPA routing implementation Update get_routes() consumers for unified route structure Complete SPA Phase 3: PHP-side route type detection and is_spa flag Restore unified routes structure and Manifest_Query class Refactor route indexing and add SPA infrastructure Phase 3 Complete: SPA route registration in manifest Implement SPA Phase 2: Extract router code and test decorators Rename Jqhtml_Component to Component and complete SPA foundation setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1064 lines
40 KiB
JavaScript
1064 lines
40 KiB
JavaScript
/**
|
|
* JQHTML Router v2.2.216
|
|
* (c) 2025 JQHTML Team
|
|
* Released under the MIT License
|
|
*/
|
|
import { logDispatch, get_component_class, Jqhtml_Component } from '@jqhtml/core';
|
|
|
|
/**
|
|
* Core Router implementation for JQHTML v2
|
|
*/
|
|
class Jqhtml_Router {
|
|
/**
|
|
* Register a route
|
|
*/
|
|
static register_route(path, component_name, component_class) {
|
|
// console.log(`[Router] Registering route: ${path} => ${component_name}`)
|
|
// Normalize path - remove trailing /index
|
|
if (path.endsWith('/index')) {
|
|
path = path.slice(0, -6) || '/';
|
|
}
|
|
// Check for duplicates in development
|
|
if (this.is_dev()) {
|
|
const existing = this.routes.find(r => r.url === path);
|
|
if (existing) {
|
|
console.error(`Duplicate routes detected for '${path}' - ${component_name} and ${existing.component_name}`);
|
|
}
|
|
}
|
|
this.routes.push({
|
|
url: path,
|
|
component_name,
|
|
component_class,
|
|
layout: component_class.layout,
|
|
meta: component_class.meta || {}
|
|
});
|
|
// console.log(`[Router] Total routes registered:`, this.routes.length)
|
|
}
|
|
/**
|
|
* Register a layout
|
|
*/
|
|
static register_layout(name, component_class) {
|
|
this.layouts.set(name, component_class);
|
|
}
|
|
/**
|
|
* Parse a URL into components
|
|
*/
|
|
static parse_url(url, base) {
|
|
let parsed;
|
|
try {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
parsed = new URL(url);
|
|
}
|
|
else if (base) {
|
|
parsed = new URL(url, base);
|
|
}
|
|
else {
|
|
// Use appropriate base URL depending on context
|
|
const defaultBase = typeof window !== 'undefined' && window.location
|
|
? window.location.href
|
|
: 'http://localhost:3000';
|
|
parsed = new URL(url, defaultBase);
|
|
}
|
|
}
|
|
catch (e) {
|
|
// Fallback for malformed URLs
|
|
const fallbackBase = typeof window !== 'undefined' && window.location
|
|
? window.location.href
|
|
: 'http://localhost:3000';
|
|
parsed = new URL(fallbackBase);
|
|
}
|
|
const port = parsed.port ? `:${parsed.port}` : '';
|
|
const host = `${parsed.protocol}//${parsed.hostname}${port}`;
|
|
let path = parsed.pathname;
|
|
// Handle file:// URLs - remove drive letter prefix
|
|
if (parsed.protocol === 'file:' && path.match(/^\/[A-Z]:/)) {
|
|
path = path.substring(3); // Remove "/Z:" prefix
|
|
}
|
|
// Normalize path - no trailing slash except for root
|
|
if (path !== '/' && path.endsWith('/')) {
|
|
path = path.slice(0, -1);
|
|
}
|
|
return {
|
|
host,
|
|
path,
|
|
search: parsed.search,
|
|
hash: parsed.hash,
|
|
href: parsed.href,
|
|
host_path: host + path,
|
|
host_path_search: host + path + parsed.search,
|
|
path_search: path + parsed.search,
|
|
path_search_hash: path + parsed.search + parsed.hash
|
|
};
|
|
}
|
|
/**
|
|
* Match a URL to a route and extract parameters
|
|
*/
|
|
static match_url_to_route(url) {
|
|
// console.log('[Router] match_url_to_route called with:', url)
|
|
// console.log('[Router] Registered routes:', this.routes.map(r => r.url))
|
|
let parsed = this.parse_url(url);
|
|
let path = parsed.path;
|
|
// For hash routing, extract path from hash
|
|
if (this.use_hash_routing && url.includes('#')) {
|
|
const hashIndex = url.indexOf('#');
|
|
const hashPart = url.substring(hashIndex + 1);
|
|
if (hashPart.startsWith('/')) {
|
|
// Parse the hash part as the actual path
|
|
const hashUrl = hashPart.split('?')[0];
|
|
path = hashUrl;
|
|
parsed = {
|
|
...parsed,
|
|
path: hashUrl,
|
|
search: hashPart.includes('?') ? '?' + hashPart.split('?')[1] : '',
|
|
hash: ''
|
|
};
|
|
}
|
|
}
|
|
// console.log('[Router] Parsed path:', path)
|
|
path = path.substring(1); // Remove leading /
|
|
// Normalize path - remove /index suffix
|
|
if (path === 'index' || path.endsWith('/index')) {
|
|
path = path.slice(0, -5) || '';
|
|
}
|
|
// Handle root path specially
|
|
const path_parts = path === '' ? [''] : path.split('/');
|
|
let matched_route = null;
|
|
let matched_params = {};
|
|
// Try to match against registered routes
|
|
for (const route of this.routes) {
|
|
const route_path = route.url.startsWith('/') ? route.url.substring(1) : route.url;
|
|
const route_parts = route_path === '' ? [''] : route_path.split('/');
|
|
// Skip if different number of segments
|
|
if (route_parts.length !== path_parts.length) {
|
|
continue;
|
|
}
|
|
let is_match = true;
|
|
const params = {};
|
|
// Check each segment
|
|
for (let i = 0; i < route_parts.length; i++) {
|
|
const route_part = route_parts[i];
|
|
const path_part = path_parts[i];
|
|
if (route_part.startsWith(':')) {
|
|
// Parameter segment - extract value
|
|
const param_name = route_part.substring(1);
|
|
params[param_name] = decodeURIComponent(path_part);
|
|
}
|
|
else if (route_part !== path_part) {
|
|
// Static segment mismatch
|
|
is_match = false;
|
|
break;
|
|
}
|
|
}
|
|
if (is_match) {
|
|
matched_route = route;
|
|
matched_params = params;
|
|
// In dev mode, continue checking for duplicates
|
|
if (!this.is_dev()) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!matched_route) {
|
|
// console.log('[Router] No route matched for path:', path)
|
|
return null;
|
|
}
|
|
// console.log('[Router] Route matched:', matched_route.component_name, 'for path:', path)
|
|
// Parse query parameters
|
|
const query_params = this.deserialize(parsed.search);
|
|
// Combine all parameters
|
|
const args = { ...matched_params, ...query_params };
|
|
// Add hash if present
|
|
if (parsed.hash) {
|
|
args.hash = parsed.hash.substring(1); // Remove leading #
|
|
}
|
|
return {
|
|
url: '/' + path, // Use the normalized path for consistency
|
|
path: '/' + path,
|
|
args,
|
|
hash: parsed.hash.substring(1),
|
|
meta: matched_route.meta || {},
|
|
component_name: matched_route.component_name,
|
|
component_class: matched_route.component_class,
|
|
layout: matched_route.layout
|
|
};
|
|
}
|
|
/**
|
|
* Convert object to query string
|
|
*/
|
|
static serialize(params) {
|
|
const parts = [];
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value !== undefined && value !== null) {
|
|
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
}
|
|
}
|
|
return parts.length > 0 ? `?${parts.join('&')}` : '';
|
|
}
|
|
/**
|
|
* Parse query string to object
|
|
*/
|
|
static deserialize(search) {
|
|
const params = {};
|
|
if (!search || search === '?') {
|
|
return params;
|
|
}
|
|
const query = search.startsWith('?') ? search.substring(1) : search;
|
|
const parts = query.split('&');
|
|
for (const part of parts) {
|
|
const [key, value] = part.split('=');
|
|
if (key) {
|
|
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
/**
|
|
* Generate URL for a route with parameters
|
|
*/
|
|
static url(route_name, params) {
|
|
// Find route by name
|
|
const route = this.routes.find(r => { var _a; return ((_a = r.meta) === null || _a === void 0 ? void 0 : _a.name) === route_name; });
|
|
if (!route) {
|
|
console.error(`Route not found: ${route_name}`);
|
|
return '#';
|
|
}
|
|
return this.build_url(route.url, params || {});
|
|
}
|
|
/**
|
|
* Build URL from pattern and parameters
|
|
*/
|
|
static build_url(pattern, params) {
|
|
const used_params = new Set();
|
|
// Replace :param placeholders
|
|
let url = pattern.replace(/:([^/]+)/g, (match, param_name) => {
|
|
if (params.hasOwnProperty(param_name)) {
|
|
used_params.add(param_name);
|
|
return encodeURIComponent(params[param_name]);
|
|
}
|
|
return '';
|
|
});
|
|
// Collect unused parameters for query string
|
|
const query_params = {};
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (!used_params.has(key) && key !== 'hash') {
|
|
query_params[key] = value;
|
|
}
|
|
}
|
|
// Add query string if needed
|
|
if (Object.keys(query_params).length > 0) {
|
|
url += this.serialize(query_params);
|
|
}
|
|
// Add hash if provided (only for non-hash routing)
|
|
if (!this.use_hash_routing && params.hash) {
|
|
url += `#${params.hash}`;
|
|
}
|
|
// Remove /index suffix
|
|
if (url.includes('/index')) {
|
|
url = url.replace('/index', '');
|
|
}
|
|
url = url || '/';
|
|
// For hash routing, prepend # to all URLs
|
|
if (this.use_hash_routing && !url.startsWith('#')) {
|
|
url = '#' + url;
|
|
}
|
|
return url;
|
|
}
|
|
/**
|
|
* Get current URL
|
|
*/
|
|
static get_current_url() {
|
|
return this.current_url;
|
|
}
|
|
/**
|
|
* Get current hash
|
|
*/
|
|
static get_current_hash() {
|
|
return this.current_hash;
|
|
}
|
|
/**
|
|
* Check if in development mode
|
|
*/
|
|
static is_dev() {
|
|
return typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production';
|
|
}
|
|
/**
|
|
* Get all registered routes (for debugging)
|
|
*/
|
|
static get_routes() {
|
|
return this.routes.map(r => ({
|
|
path: r.url,
|
|
component_name: r.component_name
|
|
}));
|
|
}
|
|
/**
|
|
* Set the default layout name
|
|
*/
|
|
static set_default_layout(layout_name) {
|
|
this.default_layout = layout_name;
|
|
}
|
|
/**
|
|
* Set the SPA app instance
|
|
*/
|
|
static set_app(app) {
|
|
this.app = app;
|
|
}
|
|
/**
|
|
* Initialize the router
|
|
* Sets up browser event handlers
|
|
*/
|
|
static async init() {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
this.initialized = true;
|
|
// Detect if we need hash routing (file:// protocol)
|
|
this.use_hash_routing = window.location.protocol === 'file:';
|
|
if (this.use_hash_routing) ;
|
|
this.first_url = window.location.href;
|
|
this.current_url = window.location.href;
|
|
this.current_hash = window.location.hash.substring(1);
|
|
// Set up browser event handlers
|
|
this.setup_browser_integration();
|
|
// Set 30-minute expiry
|
|
setTimeout(() => {
|
|
this.is_expired = true;
|
|
}, 30 * 60 * 1000);
|
|
}
|
|
/**
|
|
* Setup browser integration for link interception and history
|
|
*/
|
|
static setup_browser_integration() {
|
|
// Handle browser back/forward buttons
|
|
if (this.use_hash_routing) {
|
|
// For hash routing, listen to hashchange
|
|
window.addEventListener('hashchange', () => {
|
|
// Ignore hash changes that we triggered ourselves
|
|
if (this.ignore_next_hashchange) {
|
|
// console.log('Router: ignoring self-triggered hash change')
|
|
return;
|
|
}
|
|
// Ignore hash changes while we're already dispatching
|
|
if (this.is_dispatching) {
|
|
// console.log('Router: ignoring hash change during dispatch')
|
|
return;
|
|
}
|
|
// console.log('Router: hash change detected')
|
|
const hashPath = window.location.hash.substring(1) || '/';
|
|
this.dispatch(hashPath, { modify_history: false });
|
|
});
|
|
}
|
|
else {
|
|
// For normal routing, listen to popstate
|
|
window.addEventListener('popstate', () => {
|
|
const current_parsed = this.parse_url(this.current_url);
|
|
const target_parsed = this.parse_url(window.location.href);
|
|
if (current_parsed.host_path_search !== target_parsed.host_path_search) {
|
|
// console.log('Router: browser navigation detected')
|
|
this.dispatch(window.location.href, { modify_history: false });
|
|
}
|
|
});
|
|
}
|
|
// Track ctrl key for link handling
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Control' || e.metaKey) {
|
|
this.ctrl_pressed = true;
|
|
}
|
|
});
|
|
document.addEventListener('keyup', (e) => {
|
|
if (e.key === 'Control' || !e.metaKey) {
|
|
this.ctrl_pressed = false;
|
|
}
|
|
});
|
|
// Intercept link clicks
|
|
document.addEventListener('click', (e) => {
|
|
// Check if expired
|
|
if (this.is_expired) {
|
|
return;
|
|
}
|
|
// Find the link element
|
|
let link = e.target;
|
|
while (link && link.tagName !== 'A') {
|
|
link = link.parentElement;
|
|
}
|
|
if (!link || link.tagName !== 'A') {
|
|
return;
|
|
}
|
|
const href = link.getAttribute('href');
|
|
// Ignore if:
|
|
// - No href
|
|
// - Ctrl/Cmd is pressed (open in new tab)
|
|
// - Has target attribute
|
|
// - Not left click
|
|
// - Starts with # only (unless we're in hash routing mode)
|
|
if (!href ||
|
|
this.ctrl_pressed ||
|
|
link.getAttribute('target') ||
|
|
e.button !== 0) {
|
|
return;
|
|
}
|
|
// Special handling for hash routing
|
|
if (this.use_hash_routing) {
|
|
// Convert regular paths to hash paths
|
|
if (href.startsWith('/') && !href.startsWith('//')) {
|
|
e.preventDefault();
|
|
this.dispatch(href);
|
|
return;
|
|
}
|
|
// Handle hash links
|
|
if (href.startsWith('#/')) {
|
|
e.preventDefault();
|
|
this.dispatch(href.substring(1));
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
// Normal routing - ignore pure hash links
|
|
if (href === '#' || (href.startsWith('#') && !href.substring(1))) {
|
|
return;
|
|
}
|
|
const current_parsed = this.parse_url(this.current_url);
|
|
const target_parsed = this.parse_url(href);
|
|
// Only intercept same-domain navigation
|
|
if (current_parsed.host === target_parsed.host) {
|
|
e.preventDefault();
|
|
this.dispatch(target_parsed.href);
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Main dispatch method - handles navigation
|
|
*/
|
|
static async dispatch(url, options = {}) {
|
|
// console.log('[Router] dispatch called with:', url)
|
|
// Set dispatching flag to prevent re-entrant dispatches
|
|
this.is_dispatching = true;
|
|
// Default options
|
|
const opts = {
|
|
modify_history: true,
|
|
trigger_events: true,
|
|
scroll_top: true,
|
|
...options
|
|
};
|
|
// For hash routing, handle the URL differently
|
|
let routeUrl = url;
|
|
let target_parsed;
|
|
let current_parsed;
|
|
if (this.use_hash_routing) {
|
|
// For hash routing, don't parse the URL as absolute
|
|
// Just ensure it starts with /
|
|
if (!url.startsWith('/') && !url.startsWith('#')) {
|
|
routeUrl = '/' + url;
|
|
}
|
|
else if (url.startsWith('#')) {
|
|
routeUrl = url.substring(1);
|
|
}
|
|
// Parse current state for comparison
|
|
current_parsed = this.parse_url(this.current_url);
|
|
// For target, we just need a simple structure
|
|
target_parsed = {
|
|
...current_parsed,
|
|
path: routeUrl,
|
|
href: window.location.href.split('#')[0] + '#' + routeUrl,
|
|
path_search: routeUrl,
|
|
path_search_hash: routeUrl
|
|
};
|
|
}
|
|
else {
|
|
// Normal routing - parse URLs normally
|
|
target_parsed = this.parse_url(url);
|
|
current_parsed = this.parse_url(this.current_url);
|
|
routeUrl = url;
|
|
}
|
|
// Update current URL state
|
|
this.current_url = target_parsed.href;
|
|
this.current_hash = target_parsed.hash.substring(1);
|
|
// Update browser history
|
|
if (opts.modify_history) {
|
|
if (this.use_hash_routing) {
|
|
// For hash routing, update the hash
|
|
const hashPath = routeUrl.startsWith('#') ? routeUrl : '#' + routeUrl;
|
|
if (window.location.hash !== hashPath) {
|
|
// We're about to change the hash, which will trigger a hashchange event
|
|
// Mark that we should ignore it since this dispatch is already handling it
|
|
this.ignore_next_hashchange = true;
|
|
window.location.hash = hashPath;
|
|
// Clear the flag after the event loop to ensure hashchange has fired
|
|
setTimeout(() => {
|
|
this.ignore_next_hashchange = false;
|
|
}, 0);
|
|
}
|
|
}
|
|
else {
|
|
// Normal routing with pushState
|
|
if (current_parsed.host_path_search !== target_parsed.host_path_search) {
|
|
// Different page - push state
|
|
window.history.pushState({}, '', target_parsed.path_search_hash);
|
|
}
|
|
else if (current_parsed.hash !== target_parsed.hash) {
|
|
// Same page, different hash - replace state
|
|
window.history.replaceState({}, '', target_parsed.path_search_hash);
|
|
}
|
|
}
|
|
}
|
|
// Match URL to route (use the normalized routeUrl for hash routing)
|
|
const route_info = this.match_url_to_route(this.use_hash_routing ? routeUrl : url);
|
|
// console.log('[Router] Route info:', route_info)
|
|
// Debug logging
|
|
if (route_info) {
|
|
logDispatch(url, route_info, route_info.args, false);
|
|
}
|
|
if (!route_info) {
|
|
// No route matched - navigate to server
|
|
if (target_parsed.host_path_search !== this.parse_url(this.first_url).host_path_search) {
|
|
window.location.href = target_parsed.href;
|
|
this.is_dispatching = false;
|
|
return;
|
|
}
|
|
console.error('No route matched and on same domain:', url);
|
|
this.is_dispatching = false;
|
|
return;
|
|
}
|
|
// Store current route info
|
|
const old_route_info = this.current_route_info;
|
|
this.current_route_info = route_info;
|
|
try {
|
|
// Execute pre-dispatch hooks
|
|
if (opts.trigger_events && this.app) {
|
|
// App pre_dispatch
|
|
const app_result = await this.app.pre_dispatch(route_info);
|
|
if (app_result === false) {
|
|
// Navigation cancelled
|
|
this.current_route_info = old_route_info;
|
|
this.is_dispatching = false;
|
|
return;
|
|
}
|
|
else if (typeof app_result === 'string') {
|
|
// Redirect requested
|
|
this.is_dispatching = false;
|
|
await this.dispatch(app_result);
|
|
return;
|
|
}
|
|
}
|
|
// Get or create layout
|
|
const layout_name = route_info.layout || this.default_layout;
|
|
let layout_component = this.current_layout;
|
|
// Check if we need a new layout
|
|
if (!layout_component || layout_component.constructor.name !== layout_name) {
|
|
// console.log(`[Router] Need to create layout: ${layout_name}, current: ${layout_component?.constructor.name || 'none'}`)
|
|
// Destroy old layout if exists
|
|
if (layout_component) {
|
|
// console.log('[Router] Destroying old layout:', layout_component.constructor.name)
|
|
layout_component.destroy();
|
|
}
|
|
// Get layout class
|
|
const LayoutClass = get_component_class(layout_name);
|
|
if (!LayoutClass) {
|
|
throw new Error(`Layout not found: ${layout_name}`);
|
|
}
|
|
// Find the container for layouts
|
|
// If we have an app (SPA), render inside it
|
|
// Otherwise render directly in #app
|
|
let $container;
|
|
if (this.app && this.app.$) {
|
|
// Render layout inside the SPA component
|
|
// console.log('[Router] Rendering layout inside SPA component')
|
|
$container = this.app.$;
|
|
}
|
|
else {
|
|
// No SPA, render directly in #app
|
|
// console.log('[Router] Rendering layout directly in #app')
|
|
$container = $('#app');
|
|
if (!$container.length) {
|
|
throw new Error('No #app element found in document');
|
|
}
|
|
}
|
|
// Empty the container (either SPA content or #app)
|
|
$container.empty();
|
|
// Create layout component
|
|
// console.log('[Router] Creating new layout component:', layout_name)
|
|
layout_component = new LayoutClass({});
|
|
$container.append(layout_component.$);
|
|
// console.log('[Router] Layout DOM appended, waiting for ready state')
|
|
// Wait for layout to be ready before we can find elements in it
|
|
// The component registers itself with LifecycleManager automatically
|
|
await new Promise((resolve) => {
|
|
const checkReady = () => {
|
|
if (layout_component._ready_state >= 4) {
|
|
resolve();
|
|
}
|
|
else {
|
|
setTimeout(checkReady, 10);
|
|
}
|
|
};
|
|
checkReady();
|
|
});
|
|
this.current_layout = layout_component;
|
|
this.state.layout = layout_name;
|
|
// console.log('[Router] Layout ready and stored as current_layout')
|
|
}
|
|
else {
|
|
// console.log('[Router] Reusing existing layout:', layout_name)
|
|
}
|
|
// Layout pre_dispatch
|
|
if (opts.trigger_events && layout_component.pre_dispatch) {
|
|
const layout_result = await layout_component.pre_dispatch(route_info);
|
|
if (layout_result === false) {
|
|
// Navigation cancelled
|
|
this.current_route_info = old_route_info;
|
|
this.is_dispatching = false;
|
|
return;
|
|
}
|
|
else if (typeof layout_result === 'string') {
|
|
// Redirect requested
|
|
this.is_dispatching = false;
|
|
await this.dispatch(layout_result);
|
|
return;
|
|
}
|
|
}
|
|
// Get route component class
|
|
const RouteClass = route_info.component_class;
|
|
if (!RouteClass) {
|
|
throw new Error(`Route component class not found for: ${route_info.component_name}`);
|
|
}
|
|
// Create route component with args
|
|
const route_component = new RouteClass({ args: route_info.args });
|
|
// Route pre_dispatch
|
|
if (opts.trigger_events && route_component.pre_dispatch) {
|
|
const route_result = await route_component.pre_dispatch();
|
|
if (route_result === false) {
|
|
// Navigation cancelled
|
|
route_component.destroy();
|
|
this.current_route_info = old_route_info;
|
|
this.is_dispatching = false;
|
|
return;
|
|
}
|
|
else if (typeof route_result === 'string') {
|
|
// Redirect requested
|
|
route_component.destroy();
|
|
this.is_dispatching = false;
|
|
await this.dispatch(route_result);
|
|
return;
|
|
}
|
|
}
|
|
// Destroy old route if exists
|
|
if (this.current_route) {
|
|
this.current_route.destroy();
|
|
}
|
|
// Render route into layout
|
|
await layout_component._render_route(route_component);
|
|
this.current_route = route_component;
|
|
// Wait for route to be ready before continuing
|
|
// This ensures the route's lifecycle completes before we finish dispatch
|
|
await new Promise((resolve) => {
|
|
const checkReady = () => {
|
|
if (route_component._ready_state >= 4) {
|
|
resolve();
|
|
}
|
|
else {
|
|
setTimeout(checkReady, 10);
|
|
}
|
|
};
|
|
checkReady();
|
|
});
|
|
// Update state
|
|
this.state.route = route_info.component_name;
|
|
this.state.url = route_info.url;
|
|
this.state.args = route_info.args;
|
|
this.state.hash = route_info.hash;
|
|
// Notify layout of route change
|
|
if (layout_component.on_route_change) {
|
|
await layout_component.on_route_change(old_route_info, route_info);
|
|
}
|
|
// Execute post-dispatch hooks in reverse order
|
|
if (opts.trigger_events) {
|
|
// Route post_dispatch
|
|
if (route_component.post_dispatch) {
|
|
await route_component.post_dispatch();
|
|
}
|
|
// Layout post_dispatch
|
|
if (layout_component.post_dispatch) {
|
|
await layout_component.post_dispatch(route_info);
|
|
}
|
|
// App post_dispatch
|
|
if (this.app && this.app.post_dispatch) {
|
|
await this.app.post_dispatch(route_info);
|
|
}
|
|
}
|
|
// Scroll to top if requested
|
|
if (opts.scroll_top) {
|
|
window.scrollTo(0, 0);
|
|
}
|
|
// console.log('Route dispatched successfully:', route_info.component_name)
|
|
// Clear dispatching flag
|
|
this.is_dispatching = false;
|
|
}
|
|
catch (error) {
|
|
console.error('Dispatch error:', error);
|
|
// Restore previous route info on error
|
|
this.current_route_info = old_route_info;
|
|
// Clear dispatching flag even on error
|
|
this.is_dispatching = false;
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Navigate and replace current history entry
|
|
*/
|
|
static redirect(url) {
|
|
this.dispatch(url, { modify_history: true });
|
|
}
|
|
/**
|
|
* Navigate and replace current history entry
|
|
*/
|
|
static replace(url) {
|
|
if (this.use_hash_routing) {
|
|
// For hash routing, just update the hash
|
|
const hashPath = url.startsWith('#') ? url : '#' + url;
|
|
window.location.hash = hashPath;
|
|
this.dispatch(url, { modify_history: false });
|
|
}
|
|
else {
|
|
const parsed = this.parse_url(url);
|
|
window.history.replaceState({}, '', parsed.path_search_hash);
|
|
this.dispatch(url, { modify_history: false });
|
|
}
|
|
}
|
|
}
|
|
// Router state
|
|
Jqhtml_Router.state = {
|
|
route: null,
|
|
layout: null,
|
|
args: {},
|
|
url: '',
|
|
hash: ''
|
|
};
|
|
// Router configuration
|
|
Jqhtml_Router.routes = [];
|
|
Jqhtml_Router.layouts = new Map();
|
|
Jqhtml_Router.initialized = false;
|
|
Jqhtml_Router.ctrl_pressed = false;
|
|
Jqhtml_Router.is_expired = false;
|
|
Jqhtml_Router.first_url = '';
|
|
Jqhtml_Router.current_url = '';
|
|
Jqhtml_Router.current_hash = '';
|
|
Jqhtml_Router.default_layout = 'Default_Layout';
|
|
Jqhtml_Router.app = null;
|
|
Jqhtml_Router.current_route_info = null;
|
|
Jqhtml_Router.current_layout = null;
|
|
Jqhtml_Router.current_route = null;
|
|
Jqhtml_Router.use_hash_routing = false;
|
|
Jqhtml_Router.is_dispatching = false;
|
|
Jqhtml_Router.ignore_next_hashchange = false;
|
|
|
|
/**
|
|
* Base class for route components in JQHTML Router v2
|
|
*/
|
|
class Jqhtml_Route extends Jqhtml_Component {
|
|
constructor(options) {
|
|
super(options);
|
|
// Mark this as a route component
|
|
this._is_route = true;
|
|
// Route parameters from URL
|
|
this.args = {};
|
|
// Extract args from options if provided
|
|
if (options && options.args) {
|
|
this.args = options.args;
|
|
}
|
|
}
|
|
/**
|
|
* Called during app initialization to register routes
|
|
* This is where routes call register_route for each path they handle
|
|
*/
|
|
static init() {
|
|
// console.log(`[Route] Initializing ${this.name} with routes:`, this.routes)
|
|
// Register each route path with the router
|
|
for (const route of this.routes) {
|
|
Jqhtml_Router.register_route(route, this.name, this);
|
|
}
|
|
}
|
|
/**
|
|
* Generate URL for this route with given parameters
|
|
* Static version for generating URLs without an instance
|
|
*/
|
|
static url(params) {
|
|
// If this route has a name in meta, use it
|
|
if (this.meta.name) {
|
|
return Jqhtml_Router.url(this.meta.name, params);
|
|
}
|
|
// Otherwise use the first route pattern
|
|
if (this.routes.length > 0) {
|
|
return Jqhtml_Router.build_url(this.routes[0], params || {});
|
|
}
|
|
console.error(`Route ${this.name} has no routes defined`);
|
|
return '#';
|
|
}
|
|
/**
|
|
* Generate URL for this route with current args merged with new params
|
|
* Instance method that includes current route parameters
|
|
*/
|
|
url(params) {
|
|
// Merge current args with new params
|
|
const merged_params = { ...this.args, ...params };
|
|
return this.constructor.url(merged_params);
|
|
}
|
|
/**
|
|
* Navigate to this route with given parameters
|
|
* Static version for programmatic navigation
|
|
*/
|
|
static dispatch(params) {
|
|
const url = this.url(params);
|
|
Jqhtml_Router.dispatch(url);
|
|
}
|
|
/**
|
|
* Navigate to this route with current args merged with new params
|
|
* Instance method for navigation from within a route
|
|
*/
|
|
dispatch(params) {
|
|
const url = this.url(params);
|
|
Jqhtml_Router.dispatch(url);
|
|
}
|
|
/**
|
|
* Called before this route is activated
|
|
* Can be used for route-specific guards
|
|
*/
|
|
async pre_dispatch() {
|
|
// Default allows activation
|
|
return true;
|
|
}
|
|
/**
|
|
* Called after this route has fully loaded
|
|
* Can be used for analytics, etc.
|
|
*/
|
|
async post_dispatch() {
|
|
// Default implementation does nothing
|
|
}
|
|
/**
|
|
* Override on_render for custom route rendering
|
|
* By default does nothing - routes should override this or use templates
|
|
*/
|
|
async on_render() {
|
|
// Default implementation does nothing
|
|
// Routes should override this method or use templates
|
|
}
|
|
}
|
|
// Route configuration
|
|
Jqhtml_Route.routes = [];
|
|
Jqhtml_Route.layout = 'Default_Layout';
|
|
Jqhtml_Route.meta = {};
|
|
|
|
/**
|
|
* Base class for layout components in JQHTML Router v2
|
|
*/
|
|
class Jqhtml_Layout extends Jqhtml_Component {
|
|
constructor(options) {
|
|
super(options);
|
|
// Mark this as a layout component
|
|
this._is_layout = true;
|
|
}
|
|
/**
|
|
* Called when the route changes within the same layout
|
|
* Override this to update layout state (e.g., active navigation items)
|
|
*/
|
|
async on_route_change(old_route, new_route) {
|
|
// Default implementation does nothing
|
|
// Subclasses can override to handle route changes
|
|
}
|
|
/**
|
|
* Called before dispatching to a new route
|
|
* Can cancel navigation by returning false or redirect by returning a URL
|
|
*/
|
|
async pre_dispatch(route_info) {
|
|
// Default allows all navigation
|
|
return true;
|
|
}
|
|
/**
|
|
* Called after a route has fully loaded
|
|
* Can trigger redirects for post-load logic
|
|
*/
|
|
async post_dispatch(route_info) {
|
|
// Default implementation does nothing
|
|
}
|
|
/**
|
|
* Get the content container where routes render
|
|
* Must contain an element with $id="content"
|
|
*/
|
|
$content() {
|
|
const $content = this.$id('content');
|
|
if (!$content.length) {
|
|
throw new Error(`Layout ${this.constructor.name} must have an element with $id="content"`);
|
|
}
|
|
return $content;
|
|
}
|
|
/**
|
|
* Internal method to render a route into this layout
|
|
* Called by the router during dispatch
|
|
*/
|
|
async _render_route(route_component) {
|
|
const $content = this.$content();
|
|
// Clear existing content
|
|
$content.empty();
|
|
// Create container for route
|
|
const $route_container = $('<div></div>');
|
|
$content.append($route_container);
|
|
// Mount the route component
|
|
// The route component will be created by the dispatch system
|
|
// and passed here already initialized
|
|
$route_container.append(route_component.$);
|
|
}
|
|
/**
|
|
* Override on_render for custom layout rendering
|
|
* By default does nothing - layouts should override this or use templates
|
|
*/
|
|
async on_render() {
|
|
// Default implementation does nothing
|
|
// Layouts should override this method or use templates
|
|
}
|
|
// Layouts should use templates or override on_render(), not render()
|
|
/**
|
|
* Layouts should never re-render after initial load
|
|
* They persist across route changes
|
|
*/
|
|
should_rerender() {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Base application class for JQHTML SPAs
|
|
*/
|
|
class Jqhtml_SPA extends Jqhtml_Component {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.initialized = false;
|
|
this._initial_url = null;
|
|
}
|
|
/**
|
|
* Initialize the router and set up event handlers
|
|
*/
|
|
async init_router(config = {}) {
|
|
// console.log('[SPA] Initializing router with config:', config);
|
|
try {
|
|
// Configure router
|
|
if (config.default_layout) {
|
|
// console.log('[SPA] Setting default layout:', config.default_layout);
|
|
Jqhtml_Router.set_default_layout(config.default_layout);
|
|
}
|
|
// TODO: Add not_found_component support to router
|
|
if (config.not_found_component) {
|
|
// console.log('[SPA] Warning: not_found_component not yet implemented in router');
|
|
}
|
|
// Set this SPA as the app instance
|
|
// console.log('[SPA] Setting SPA as router app');
|
|
Jqhtml_Router.set_app(this);
|
|
// Initialize the router
|
|
// console.log('[SPA] Initializing router...');
|
|
await Jqhtml_Router.init();
|
|
// Determine initial URL based on routing mode
|
|
let initial_url;
|
|
// Check if router is using hash routing
|
|
const use_hash_routing = Jqhtml_Router.use_hash_routing;
|
|
if (use_hash_routing) {
|
|
// For hash routing, always use the hash part
|
|
if (window.location.hash && window.location.hash.length > 1) {
|
|
initial_url = window.location.hash.substring(1);
|
|
// console.log('[SPA] Using hash for initial URL:', initial_url);
|
|
}
|
|
else {
|
|
initial_url = "/";
|
|
// console.log('[SPA] No hash found, using default root path');
|
|
}
|
|
}
|
|
else {
|
|
// For normal routing, use pathname + search
|
|
initial_url = window.location.pathname + window.location.search;
|
|
// console.log('[SPA] Initial URL from location:', initial_url);
|
|
// Handle direct .html access
|
|
const parsed = Jqhtml_Router.parse_url(initial_url);
|
|
// console.log('[SPA] Parsed URL:', parsed);
|
|
if (parsed.path === "/" || parsed.path.endsWith(".html")) {
|
|
// Check for hash-based routing fallback
|
|
if (window.location.hash && window.location.hash.length > 1 && window.location.hash.startsWith('#/')) {
|
|
initial_url = window.location.hash.substring(1);
|
|
// console.log('[SPA] Using hash fallback for initial URL:', initial_url);
|
|
}
|
|
else {
|
|
initial_url = "/";
|
|
// console.log('[SPA] Using default root path');
|
|
}
|
|
}
|
|
}
|
|
// Store initial URL for dispatching in on_ready
|
|
this._initial_url = initial_url;
|
|
// console.log('[SPA] Stored initial URL, will dispatch in on_ready phase');
|
|
// console.log('[SPA] Router initialization complete');
|
|
}
|
|
catch (error) {
|
|
console.error('[SPA] Failed to initialize router:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* Called when the SPA component is fully ready
|
|
* This is where we initialize the router and dispatch the initial route
|
|
*/
|
|
async on_ready() {
|
|
// console.log('[SPA] on_ready() called');
|
|
if (this.initialized) {
|
|
console.warn('[SPA] Already initialized');
|
|
return;
|
|
}
|
|
this.initialized = true;
|
|
// Let subclass do any setup first
|
|
await super.on_ready();
|
|
// Now dispatch to the initial URL if we have one
|
|
if (this._initial_url) {
|
|
// console.log('[SPA] Dispatching to initial URL:', this._initial_url);
|
|
await Jqhtml_Router.dispatch(this._initial_url, {
|
|
modify_history: false, // Don't modify history for initial load
|
|
trigger_events: true,
|
|
scroll_top: false // Preserve scroll position on initial load
|
|
});
|
|
}
|
|
// console.log('[SPA] Component ready, router initialized and initial route dispatched');
|
|
}
|
|
/**
|
|
* Called before dispatching to a new route
|
|
* Can cancel navigation by returning false or redirect by returning a URL
|
|
* This runs before layout.pre_dispatch()
|
|
*/
|
|
async pre_dispatch(route_info) {
|
|
// Default allows all navigation
|
|
return true;
|
|
}
|
|
/**
|
|
* Called after a route has fully loaded
|
|
* This runs after layout.post_dispatch()
|
|
* Can trigger redirects for post-load logic
|
|
*/
|
|
async post_dispatch(route_info) {
|
|
// Default implementation does nothing
|
|
}
|
|
/**
|
|
* Set the default layout name
|
|
* Call this in on_create() if you want a different default than 'Default_Layout'
|
|
*/
|
|
static set_default_layout(layout_name) {
|
|
Jqhtml_Router.set_default_layout(layout_name);
|
|
}
|
|
/**
|
|
* Get current router state
|
|
*/
|
|
static get state() {
|
|
return Jqhtml_Router.state;
|
|
}
|
|
/**
|
|
* Get current route info
|
|
*/
|
|
static get current_route() {
|
|
return Jqhtml_Router.current_route_info || null;
|
|
}
|
|
}
|
|
|
|
export { Jqhtml_Layout, Jqhtml_Route, Jqhtml_Router, Jqhtml_SPA };
|
|
//# sourceMappingURL=jqhtml-router.esm.js.map
|