Files
rspade_system/node_modules/@jqhtml/router/dist/jqhtml-router.esm.js
root 9ebcc359ae Fix code quality violations and enhance ROUTE-EXISTS-01 rule
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>
2025-11-19 17:48:15 +00:00

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