Fix bin/publish: use correct .env path for rspade_system Fix bin/publish script: prevent grep exit code 1 from terminating script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1059 lines
39 KiB
JavaScript
Executable File
1059 lines
39 KiB
JavaScript
Executable File
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=index.js.map
|