Fix code quality violations for publish

Progressive breadcrumb resolution with caching, fix double headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-16 04:43:47 +00:00
parent ba3268caca
commit 14dd2fd223
1456 changed files with 136243 additions and 7631 deletions

404
app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js Normal file → Executable file
View File

@@ -1,58 +1,58 @@
/** /**
* Rsx_Breadcrumb_Resolver - Progressive breadcrumb resolution with caching * Rsx_Breadcrumb_Resolver - Progressive breadcrumb resolution with caching
* *
* Provides instant breadcrumb rendering using cached data while validating * Provides instant breadcrumb rendering using cached data while resolving
* against live action data in the background. * live labels progressively in the background.
* *
* Resolution Phases: * Resolution Flow:
* 1. Immediate Cached Render - If ALL chain links cached, render instantly * 1. Keep previous breadcrumbs visible initially
* 2. Active Action Resolution - Await live action's breadcrumb methods * 2. Try to load cached chain - if complete, display immediately
* 3. Chain Walk - Walk parents using cache or detached action loads * 3. Walk breadcrumb_parent() chain to get structure (URLs)
* 4. Render Chain - Emit chain structure (may have null labels) * 4. Compare structure to cache:
* 5. Progressive Updates - Update labels as they resolve * - Different length or no cache shown: render skeleton with blank labels
* - Same length: render skeleton pre-populated with cached labels
* 5. Progressively resolve each label via breadcrumb_label()/breadcrumb_label_active()
* 6. Cache final result when all labels resolved
*
* Important: Actions that need loaded data in breadcrumb methods should
* await their own 'loaded' event internally, not rely on ready() being called.
* *
* Usage: * Usage:
* const cancel = Rsx_Breadcrumb_Resolver.stream(action, url, (data) => { * const cancel = Rsx_Breadcrumb_Resolver.stream(action, url, callbacks);
* // data.title - Page title (null if pending)
* // data.chain - Array of {url, label, is_active, resolved}
* // data.fully_resolved - True when all labels resolved
* });
*
* // Call cancel() to stop streaming (e.g., on navigation)
*/ */
class Rsx_Breadcrumb_Resolver { class Rsx_Breadcrumb_Resolver {
/**
* In-memory cache: url -> { label, label_active, parent_url }
* Cleared on page reload. Invalidated by visiting pages.
*/
static _cache = {};
/** /**
* Generation counter for cancellation detection. * Generation counter for cancellation detection.
* Incremented on each stream() call and when cancelled.
*/ */
static _generation = 0; static _generation = 0;
/**
* Cache key prefix for breadcrumb chains
*/
static CACHE_PREFIX = 'breadcrumb_chain:';
/** /**
* Stream breadcrumb data as it becomes available * Stream breadcrumb data as it becomes available
* *
* @param {Spa_Action} action - The active action instance * @param {Spa_Action} action - The active action instance
* @param {string} url - Current URL (pathname + search, no hash) * @param {string} url - Current URL (pathname + search, no hash)
* @param {Function} callback - Called with breadcrumb data on each update * @param {object} callbacks - Callback functions:
* - on_chain(chain): Called when chain structure is ready (may have null labels)
* - on_label_update(index, label): Called when individual label resolves
* - on_complete(chain): Called when all labels resolved
* @returns {Function} Cancel function - call to stop streaming * @returns {Function} Cancel function - call to stop streaming
*/ */
static stream(action, url, callback) { static stream(action, url, callbacks) {
const generation = ++Rsx_Breadcrumb_Resolver._generation; const generation = ++Rsx_Breadcrumb_Resolver._generation;
// Normalize URL (strip hash if present) // Normalize URL (strip hash if present)
const normalized_url = Rsx_Breadcrumb_Resolver._normalize_url(url); const normalized_url = Rsx_Breadcrumb_Resolver._normalize_url(url);
// Start async resolution (fires callbacks as data becomes available) // Start async resolution
Rsx_Breadcrumb_Resolver._resolve(action, normalized_url, generation, callback); Rsx_Breadcrumb_Resolver._resolve(action, normalized_url, generation, callbacks);
// Return cancel function // Return cancel function
return () => { return () => {
// Only increment if this is still the active generation
if (generation === Rsx_Breadcrumb_Resolver._generation) { if (generation === Rsx_Breadcrumb_Resolver._generation) {
Rsx_Breadcrumb_Resolver._generation++; Rsx_Breadcrumb_Resolver._generation++;
} }
@@ -75,217 +75,183 @@ class Rsx_Breadcrumb_Resolver {
} }
/** /**
* Main resolution orchestrator * Get cached chain for a URL
*/ */
static async _resolve(action, url, generation, callback) { static _get_cached_chain(url) {
// Phase 1: Try immediate cached render const cache_key = Rsx_Breadcrumb_Resolver.CACHE_PREFIX + url;
const cached_chain = Rsx_Breadcrumb_Resolver._try_build_cached_chain(url); return Rsx_Storage.session_get(cache_key);
if (cached_chain) {
// Full cache hit - render immediately
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callback({
title: null, // Title will be resolved in Phase 2
chain: cached_chain,
fully_resolved: false
});
}
// Phase 2: Await active action resolution
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
const active_data = await Rsx_Breadcrumb_Resolver._resolve_active_action(action, url);
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
// Check if parent changed from cached value (stale cache)
const cached_entry = Rsx_Breadcrumb_Resolver._cache[url];
const parent_changed = cached_chain && cached_entry &&
cached_entry.parent_url !== active_data.parent_url;
// Phase 3-5: Walk chain and render progressively
await Rsx_Breadcrumb_Resolver._walk_and_render_chain(
active_data,
url,
generation,
callback,
parent_changed || !cached_chain // Force re-render if parent changed or no cached render
);
} }
/** /**
* Phase 1: Try to build a complete chain from cache * Cache a resolved chain
* Returns null if any link is missing from cache
*/ */
static _try_build_cached_chain(url) { static _cache_chain(url, chain) {
const chain = []; const cache_key = Rsx_Breadcrumb_Resolver.CACHE_PREFIX + url;
let current_url = url; // Store simplified chain data (url, label, is_active)
const cache_data = chain.map(item => ({
while (current_url) { url: item.url,
const entry = Rsx_Breadcrumb_Resolver._cache[current_url]; label: item.label,
if (!entry) { is_active: item.is_active
// Cache miss - can't build complete chain }));
return null; Rsx_Storage.session_set(cache_key, cache_data);
} }
/**
* Main resolution orchestrator
*/
static async _resolve(action, url, generation, callbacks) {
// Step 1: Try to get cached chain
const cached_chain = Rsx_Breadcrumb_Resolver._get_cached_chain(url);
let showed_cached = false;
if (cached_chain && is_array(cached_chain) && cached_chain.length > 0) {
// Show cached chain immediately
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callbacks.on_chain(cached_chain.map(item => ({
url: item.url,
label: item.label,
is_active: item.is_active,
resolved: true
})));
showed_cached = true;
}
// Step 2: Walk the chain structure to get URLs
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
const chain_structure = await Rsx_Breadcrumb_Resolver._walk_chain_structure(action, url, generation);
if (!chain_structure || !Rsx_Breadcrumb_Resolver._is_active(generation)) return;
// Step 3: Compare to cache and build display chain
const chain_length_matches = showed_cached && cached_chain.length === chain_structure.length;
const should_use_cached_labels = showed_cached && chain_length_matches;
// Build the chain with either cached labels or nulls
const chain = chain_structure.map((item, index) => {
let label = null;
if (should_use_cached_labels && cached_chain[index]) {
label = cached_chain[index].label;
}
return {
url: item.url,
label: label,
is_active: item.is_active,
resolved: false,
action: item.action // Keep action reference for label resolution
};
});
// Step 4: Emit chain structure (with cached labels or nulls)
// Only emit if we didn't show cached OR if structure differs
if (!showed_cached || !chain_length_matches) {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callbacks.on_chain(chain.map(item => ({
url: item.url,
label: item.label,
is_active: item.is_active,
resolved: item.label !== null
})));
}
// Step 5: Resolve labels progressively
const label_promises = chain.map(async (item, index) => {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
try {
let label;
if (item.is_active) {
// Active item uses breadcrumb_label_active()
label = await item.action.breadcrumb_label_active();
} else {
// Parent items use breadcrumb_label()
label = await item.action.breadcrumb_label();
}
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
// Update chain and notify
chain[index].label = label;
chain[index].resolved = true;
callbacks.on_label_update(index, label);
} catch (e) {
console.warn('Breadcrumb label resolution failed:', e);
// Use generic label when resolution fails
chain[index].label = item.is_active ? 'Page' : 'Link';
chain[index].resolved = true;
callbacks.on_label_update(index, chain[index].label);
}
});
// Wait for all labels to resolve
await Promise.all(label_promises);
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
// Step 6: Cache the final result and notify completion
Rsx_Breadcrumb_Resolver._cache_chain(url, chain);
callbacks.on_complete(chain.map(item => ({
url: item.url,
label: item.label,
is_active: item.is_active,
resolved: true
})));
}
/**
* Walk the chain structure by following breadcrumb_parent()
* Returns array of {url, is_active, action} from root to current
*/
static async _walk_chain_structure(action, url, generation) {
const chain = [];
// Start with current action
let current_url = url;
let current_action = action;
while (current_action && current_url) {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return null;
chain.unshift({ chain.unshift({
url: current_url, url: current_url,
label: current_url === url ? entry.label_active : entry.label,
is_active: current_url === url, is_active: current_url === url,
resolved: true action: current_action
}); });
current_url = entry.parent_url; // Get parent URL
let parent_url = null;
try {
parent_url = await current_action.breadcrumb_parent();
} catch (e) {
console.warn('breadcrumb_parent() failed:', e);
}
if (!parent_url) break;
// Load parent action (detached)
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return null;
const parent_action = await Spa.load_detached_action(parent_url, {
use_cached_data: true,
skip_render_and_ready: true
});
if (!parent_action) break;
current_url = parent_url;
current_action = parent_action;
} }
return chain; return chain;
} }
/** /**
* Phase 2: Resolve all breadcrumb data from the live active action * Clear cached chain for a URL (if needed for invalidation)
*/ */
static async _resolve_active_action(action, url) { static clear_cache(url) {
// Await all breadcrumb methods in parallel if (url) {
const [title, label, label_active, parent_url] = await Promise.all([ const cache_key = Rsx_Breadcrumb_Resolver.CACHE_PREFIX + url;
action.page_title(), Rsx_Storage.session_remove(cache_key);
action.breadcrumb_label(),
action.breadcrumb_label_active(),
action.breadcrumb_parent()
]);
// Update cache with fresh values
Rsx_Breadcrumb_Resolver._cache[url] = {
label: label,
label_active: label_active || label,
parent_url: parent_url || null
};
return {
title: title,
label: label,
label_active: label_active || label,
parent_url: parent_url || null
};
} }
/**
* Phase 3-5: Walk parent chain and render progressively
*/
static async _walk_and_render_chain(active_data, url, generation, callback, force_render) {
// Start chain with active action
const chain = [{
url: url,
label: active_data.label_active,
is_active: true,
resolved: true
}];
// Track pending async operations for progressive updates
let pending_count = 0;
let chain_complete = false;
// Helper to emit current state
const emit = () => {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callback({
title: active_data.title,
chain: [...chain], // Clone to prevent mutation issues
fully_resolved: chain_complete && pending_count === 0
});
};
// Walk up parent chain
let parent_url = active_data.parent_url;
while (parent_url) {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
const cached = Rsx_Breadcrumb_Resolver._cache[parent_url];
if (cached) {
// Cache hit - use cached values immediately
chain.unshift({
url: parent_url,
label: cached.label,
is_active: false,
resolved: true
});
parent_url = cached.parent_url;
} else {
// Cache miss - need to load detached action
// Add placeholder entry
const entry_url = parent_url;
chain.unshift({
url: entry_url,
label: null, // Placeholder - will be resolved
is_active: false,
resolved: false
});
pending_count++;
// Load the detached action synchronously for chain structure
const detached = await Spa.load_detached_action(entry_url, { use_cached_data: true });
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) {
if (detached) detached.stop();
return;
}
if (detached) {
// Get parent_url to continue chain walk
const next_parent = await detached.breadcrumb_parent();
// Get label while we have the action
const label = await detached.breadcrumb_label();
const label_active = await detached.breadcrumb_label_active();
// Update cache
Rsx_Breadcrumb_Resolver._cache[entry_url] = {
label: label,
label_active: label_active || label,
parent_url: next_parent || null
};
// Update chain entry with resolved label
const found_entry = chain.find(e => e.url === entry_url);
if (found_entry) {
found_entry.label = label;
found_entry.resolved = true;
pending_count--;
}
detached.stop();
parent_url = next_parent;
} else {
// Failed to load - stop chain here
parent_url = null;
}
}
}
// Chain structure complete
chain_complete = true;
// Emit final state (or intermediate if still pending)
if (force_render || pending_count === 0) {
emit();
}
}
/**
* Clear the cache (called on window reload automatically)
* Can be called manually if needed
*/
static clear_cache() {
Rsx_Breadcrumb_Resolver._cache = {};
}
/**
* Get cache entry for debugging/inspection
*/
static get_cached(url) {
return Rsx_Breadcrumb_Resolver._cache[url] || null;
} }
} }

View File

@@ -835,9 +835,22 @@ LIFECYCLE EVENT CALLBACKS
Supported Events: Supported Events:
'render' - Fires after render phase completes 'render' - Fires after render phase completes
'create' - Fires after create phase completes 'create' - Fires after create phase completes
'load' - Fires after load phase completes 'load' - Fires after load phase completes (data available)
'ready' - Fires after ready phase completes (fully initialized) 'ready' - Fires after ready phase completes (fully initialized)
Awaiting Data in Async Methods:
When a method needs data that may not be loaded yet, await the 'load'
event. If the event already fired, the callback executes immediately:
async get_display_name() {
// Wait for on_load() to complete if not already
await new Promise(resolve => this.on('load', resolve));
return `${this.data.first_name} ${this.data.last_name}`;
}
This pattern is useful for breadcrumb methods or other async accessors
that need loaded data but may be called before on_load() completes.
Basic usage: Basic usage:
// Get component instance and register callback // Get component instance and register callback
const component = $('#my-component').component(); const component = $('#my-component').component();

View File

@@ -0,0 +1,466 @@
PRIMARY_SECONDARY_NAV_BREADCRUMBS(3) RSX Framework Manual PRIMARY_SECONDARY_NAV_BREADCRUMBS(3)
NAME
Primary, Secondary Navigation and Breadcrumbs - SPA navigation systems
SYNOPSIS
// Primary navigation (sidebar) in layout
class Frontend_Spa_Layout extends Spa_Layout {
on_create() {
this.state = {
nav_sections: [
{ title: 'Overview', items: [
{ label: 'Dashboard', icon: 'bi-house', href: Rsx.Route('...') }
]}
]
};
}
}
// Secondary navigation (sublayout sidebar)
class Settings_Layout extends Spa_Layout {
static ACTION_CONFIG = {
'Settings_General_Action': { icon: 'bi-gear', label: 'General' },
'Settings_Profile_Action': { icon: 'bi-person', label: 'Profile' }
};
}
// Breadcrumb methods in actions
class Contacts_View_Action extends Spa_Action {
async page_title() { return this.data.contact.name; }
async breadcrumb_label() { return this.data.contact.name; }
async breadcrumb_label_active() { return 'View Contact'; }
async breadcrumb_parent() { return Rsx.Route('Contacts_Index_Action'); }
}
DESCRIPTION
RSX provides a comprehensive navigation system for SPAs consisting of:
1. Primary Navigation - Main sidebar with sections and links
2. Secondary Navigation - Sublayout sidebars for feature areas
3. Breadcrumbs - Progressive URL-based navigation trail
The system uses session storage caching for instant navigation feedback
while resolving live data in the background.
PRIMARY NAVIGATION
The primary navigation is typically a sidebar defined in the main SPA
layout (e.g., Frontend_Spa_Layout). It uses the Sidebar_Nav component.
Layout Structure:
// Frontend_Spa_Layout.js
class Frontend_Spa_Layout extends Spa_Layout {
on_create() {
this.state = {
nav_sections: [
{
title: 'Overview',
items: [
{
label: 'Dashboard',
icon: 'bi-house-door',
href: Rsx.Route('Dashboard_Index_Action'),
},
{
label: 'Calendar',
icon: 'bi-calendar',
href: Rsx.Route('Calendar_Index_Action'),
},
],
},
{
title: 'Business',
items: [
{ label: 'Clients', icon: 'bi-people',
href: Rsx.Route('Clients_Index_Action') },
{ label: 'Contacts', icon: 'bi-person-rolodex',
href: Rsx.Route('Contacts_Index_Action') },
],
},
],
};
}
on_action(url, action_name, args) {
// Update active state on navigation
this.sid('sidebar_nav').on_action(url, action_name, args);
}
}
Template Structure:
<Define:Frontend_Spa_Layout>
<nav class="app-sidebar">
<Sidebar_Nav $sid="sidebar_nav"
$sections=this.state.nav_sections />
</nav>
<main $sid="content"></main>
</Define:Frontend_Spa_Layout>
Sidebar_Nav Component:
Renders navigation sections with automatic active state based on
current URL. Supports icons (Bootstrap Icons), labels, and href.
on_action(url, action_name, args):
Called by layout to update active link highlighting.
Matches current URL to nav item hrefs.
SECONDARY NAVIGATION (SUBLAYOUTS)
Secondary navigation is used for feature areas with multiple related
pages (e.g., Settings). Implemented as sublayouts with their own sidebar.
Sublayout Pattern:
// Settings_Layout.js
class Settings_Layout extends Spa_Layout {
// Configure nav items per action
static ACTION_CONFIG = {
'Settings_General_Action': {
icon: 'bi-gear',
label: 'General Settings'
},
'Settings_Profile_Action': {
icon: 'bi-person',
label: 'Profile'
},
'Settings_User_Management_Index_Action': {
icon: 'bi-people',
label: 'User Management'
},
};
on_create() {
this._build_nav_items();
}
_build_nav_items() {
this.state.nav_items = [];
for (const [action_name, config] of
Object.entries(Settings_Layout.ACTION_CONFIG)) {
this.state.nav_items.push({
action: action_name,
icon: config.icon,
label: config.label,
href: Rsx.Route(action_name),
});
}
}
on_action(url, action_name, args) {
this._update_active_nav(action_name);
}
_update_active_nav(action_name) {
this.$sid('nav').find('.active').removeClass('active');
this.$sid('nav')
.find(`[data-action="${action_name}"]`)
.addClass('active');
}
}
Template Structure:
<Define:Settings_Layout>
<div class="settings-layout">
<aside class="settings-sidebar">
<nav $sid="nav">
<% for (let item of this.state.nav_items) { %>
<a href="<%= item.href %>"
data-action="<%= item.action %>"
class="settings-sidebar__item">
<i class="bi <%= item.icon %>"></i>
<span><%= item.label %></span>
</a>
<% } %>
</nav>
</aside>
<div class="settings-content" $sid="content"></div>
</div>
</Define:Settings_Layout>
Action Declaration:
Actions declare both outer and inner layouts:
@route('/frontend/settings/general')
@layout('Frontend_Spa_Layout') // Outer layout
@layout('Settings_Layout') // Sublayout (nested)
@spa('Frontend_Spa_Controller::index')
class Settings_General_Action extends Spa_Action { }
BREADCRUMB SYSTEM
Breadcrumbs provide hierarchical navigation context. The system uses
progressive resolution with caching for instant display.
Action Breadcrumb Methods:
All methods are async and can await the 'load' event if needed.
page_title()
Returns the page title for the header and document.title.
Example: "Edit: John Smith"
breadcrumb_label()
Returns the label when this page appears as a PARENT in
another page's breadcrumb trail.
Example: "John Smith" (when viewing edit page)
breadcrumb_label_active()
Returns the label when this page is the CURRENT page
(rightmost breadcrumb, no link).
Example: "Edit Contact"
breadcrumb_parent()
Returns the URL of the parent page, or null for root.
Example: Rsx.Route('Contacts_View_Action', { id: this.args.id })
Example Implementation:
class Contacts_View_Action extends Spa_Action {
on_create() {
this.data.contact = { first_name: '', last_name: '' };
}
async on_load() {
this.data.contact = await Contact_Model.fetch(this.args.id);
}
// Helper to await loaded data
async _await_loaded() {
if (this.data.contact && this.data.contact.id) return;
await new Promise(resolve => this.on('load', resolve));
}
async page_title() {
await this._await_loaded();
const c = this.data.contact;
return `${c.first_name} ${c.last_name}`.trim() || 'Contact';
}
async breadcrumb_label() {
await this._await_loaded();
const c = this.data.contact;
return `${c.first_name} ${c.last_name}`.trim() || 'Contact';
}
async breadcrumb_label_active() {
return 'View Contact'; // Static, no await needed
}
async breadcrumb_parent() {
return Rsx.Route('Contacts_Index_Action'); // Static route
}
}
Awaiting Loaded Data:
Breadcrumb methods are called BEFORE on_load() completes. If a method
needs loaded data (e.g., contact name), it must await the 'load' event:
async _await_loaded() {
// Check if data is already loaded
if (this.data.contact && this.data.contact.id) return;
// Otherwise wait for 'load' event
await new Promise(resolve => this.on('load', resolve));
}
The 'load' event fires immediately if already past that lifecycle
phase, so this pattern is safe to call multiple times.
RSX_BREADCRUMB_RESOLVER
Framework class that handles breadcrumb resolution with caching.
Location: /system/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
API:
Rsx_Breadcrumb_Resolver.stream(action, url, callbacks)
Streams breadcrumb data progressively.
Parameters:
action - Current Spa_Action instance
url - Current URL (pathname + search)
callbacks - Object with callback functions:
on_chain(chain) - Chain structure ready
on_label_update(idx, lbl) - Individual label resolved
on_complete(chain) - All labels resolved
Returns: Cancel function
Rsx_Breadcrumb_Resolver.clear_cache(url)
Clears cached breadcrumb chain for a URL.
Resolution Flow:
1. Check session cache for URL's breadcrumb chain
2. If cached, display immediately
3. Walk breadcrumb_parent() chain to get structure (URLs)
4. Compare to cache:
- Different length: render skeleton with blank labels
- Same length: render with cached labels
5. Resolve each label via breadcrumb_label()/breadcrumb_label_active()
6. Update display as each label resolves
7. Cache final result
Caching:
Uses Rsx_Storage.session_set/get() with key prefix 'breadcrumb_chain:'
Cached data: Array of { url, label, is_active }
LAYOUT INTEGRATION
The layout handles header rendering and breadcrumb streaming.
Frontend_Spa_Layout Integration:
class Frontend_Spa_Layout extends Spa_Layout {
static TITLE_CACHE_PREFIX = 'header_text:';
on_action(url, action_name, args) {
// Update page title with caching
this._update_page_title(url);
// Cancel previous breadcrumb stream
if (this._breadcrumb_cancel) {
this._breadcrumb_cancel();
}
// Start new breadcrumb stream
this._breadcrumb_cancel = Rsx_Breadcrumb_Resolver.stream(
this.action,
url,
{
on_chain: (chain) => this._on_breadcrumb_chain(chain),
on_label_update: (i, l) => this._on_breadcrumb_label_update(i, l),
on_complete: (chain) => this._on_breadcrumb_complete(chain)
}
);
}
async _update_page_title(url) {
const $title = this.$sid('page_title');
const cache_key = Frontend_Spa_Layout.TITLE_CACHE_PREFIX + url;
// Clear, show cached, then resolve live
$title.html('&nbsp;');
const cached = Rsx_Storage.session_get(cache_key);
if (cached) {
$title.html(cached);
document.title = 'RSX - ' + cached;
}
const live = await this.action.page_title();
if (live) {
$title.html(live);
document.title = 'RSX - ' + live;
Rsx_Storage.session_set(cache_key, live);
}
}
}
BREADCRUMB_NAV COMPONENT
Renders the breadcrumb trail from a chain array.
Location: /rsx/theme/components/page/breadcrumb_nav.jqhtml
Props:
$crumbs - Array of { label, url, is_active, resolved }
label: string or null (shows placeholder if null)
url: string or null (no link if null or is_active)
is_active: boolean (current page, no link)
resolved: boolean (false shows loading state)
Usage:
$breadcrumbs.component('Breadcrumb_Nav', {
crumbs: [
{ label: 'Contacts', url: '/contacts', is_active: false },
{ label: 'John Smith', url: '/contacts/view/1', is_active: false },
{ label: 'Edit Contact', url: null, is_active: true }
]
});
PAGE HEADER STRUCTURE
Standard page header with title, breadcrumbs, and action buttons.
Template Pattern:
<header class="page-title-header">
<div class="d-flex justify-content-between align-items-start">
<div>
<h1 $sid="page_title"></h1>
<nav $sid="page_breadcrumbs"></nav>
</div>
<div $sid="page_actions"></div>
</div>
</header>
Action Buttons:
Actions define page_actions() to provide header buttons:
class Contacts_View_Action extends Spa_Action {
page_actions() {
return `
<div class="d-flex gap-2">
<a href="${Rsx.Route('Contacts_Index_Action')}"
class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
<a href="${Rsx.Route('Contacts_Edit_Action', this.args.id)}"
class="btn btn-primary btn-sm">
<i class="bi bi-pencil"></i> Edit
</a>
</div>
`;
}
}
The layout renders these via _render_page_actions().
BREADCRUMB CHAIN EXAMPLE
For URL /contacts/edit/1, the breadcrumb chain resolves as:
Chain Structure (URLs):
1. /contacts (Contacts_Index_Action)
2. /contacts/view/1 (Contacts_View_Action)
3. /contacts/edit/1 (Contacts_Edit_Action) - ACTIVE
Resolution Process:
1. Contacts_Edit_Action.breadcrumb_parent() -> /contacts/view/1
2. Load detached Contacts_View_Action
3. Contacts_View_Action.breadcrumb_parent() -> /contacts
4. Load detached Contacts_Index_Action
5. Contacts_Index_Action.breadcrumb_parent() -> null (root)
Label Resolution (parallel):
- Index: breadcrumb_label() -> "Contacts"
- View: breadcrumb_label() -> "John Smith" (awaits load)
- Edit: breadcrumb_label_active() -> "Edit Contact"
Final Display:
Contacts > John Smith > Edit Contact
CACHING STRATEGY
Two caches are used for instant navigation feedback:
Page Title Cache:
Key: 'header_text:/contacts/edit/1'
Value: "Edit: John Smith"
Breadcrumb Chain Cache:
Key: 'breadcrumb_chain:/contacts/edit/1'
Value: [
{ url: '/contacts', label: 'Contacts', is_active: false },
{ url: '/contacts/view/1', label: 'John Smith', is_active: false },
{ url: '/contacts/edit/1', label: 'Edit Contact', is_active: true }
]
Cache Behavior:
- Cached values display immediately on navigation
- Live values resolve in background
- Cache updates when live values complete
- Previous breadcrumbs stay visible until new chain ready
FILES
/system/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
Breadcrumb resolution with progressive streaming and caching
/rsx/app/frontend/Frontend_Spa_Layout.js
Main layout with navigation and header integration
/rsx/theme/components/page/breadcrumb_nav.jqhtml
Breadcrumb rendering component
/rsx/theme/components/nav/sidebar_nav.jqhtml
Primary sidebar navigation component
SEE ALSO
spa(3), jqhtml(3), storage(3)
RSX Framework 2025-12-16 PRIMARY_SECONDARY_NAV_BREADCRUMBS(3)

View File

View File

View File

View File

0
app/RSpade/resource/vscode_extension/out/config.js Normal file → Executable file
View File

0
app/RSpade/resource/vscode_extension/out/config.js.map Normal file → Executable file
View File

View File

View File

View File

View File

View File

View File

View File

0
app/RSpade/resource/vscode_extension/out/extension.js Normal file → Executable file
View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

1
node_modules/.bin/jqhtml-ssr generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@jqhtml/ssr/src/server.js

1
node_modules/.bin/jqhtml-ssr-example generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@jqhtml/ssr/bin/jqhtml-ssr-example.js

353
node_modules/.package-lock.json generated vendored
View File

@@ -2211,9 +2211,9 @@
} }
}, },
"node_modules/@jqhtml/core": { "node_modules/@jqhtml/core": {
"version": "2.3.23", "version": "2.3.26",
"resolved": "http://privatenpm.hanson.xyz/@jqhtml/core/-/core-2.3.23.tgz", "resolved": "http://privatenpm.hanson.xyz/@jqhtml/core/-/core-2.3.26.tgz",
"integrity": "sha512-x1GRtgiXilyHieAtGBqXFrIM+EUZA7a9OW53yEtDymmYgqTDxH+lOd+eI2b0imqkEa8dh/xzgmueyjl+7WtHpw==", "integrity": "sha512-R52gWe7fNP/v+Xn6X83qAVdDoKjBbnUx50W3KSDvF6zftjInTMijAsLba9Ci+LXe1n6lIs2YDzv/CEWpG6dncw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
@@ -2237,9 +2237,9 @@
} }
}, },
"node_modules/@jqhtml/parser": { "node_modules/@jqhtml/parser": {
"version": "2.3.23", "version": "2.3.26",
"resolved": "http://privatenpm.hanson.xyz/@jqhtml/parser/-/parser-2.3.23.tgz", "resolved": "http://privatenpm.hanson.xyz/@jqhtml/parser/-/parser-2.3.26.tgz",
"integrity": "sha512-hqdTufvDRJ2aRd//atFJ06s8XQ7CBBfJEhYdfinWQGjir2coAiQk+HZIVQP8VCc/2LYFCmuqVnHVpqWanG5lyg==", "integrity": "sha512-ZGVb+LFzW1FnyTgsaxYzwZ+fnJEDbgKheQOYeqpkEfIm30z7K+h+zYtKJjbdGy9fjXSSOIV5FpNHeinlCDQGTQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
@@ -2276,10 +2276,106 @@
"jquery": "^3.7.0" "jquery": "^3.7.0"
} }
}, },
"node_modules/@jqhtml/ssr": {
"version": "2.3.26",
"resolved": "http://privatenpm.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.26.tgz",
"integrity": "sha512-J5RYOr9G9qC4alOCwwHluqdnDRSeymDE4oW8meCDg5Yp+IY53WS+6q8l0+7/DyKUnwjm4iAp90/muuU+Gv8Lzw==",
"license": "MIT",
"dependencies": {
"jquery": "^3.7.1",
"jsdom": "^24.0.0"
},
"bin": {
"jqhtml-ssr": "src/server.js",
"jqhtml-ssr-example": "bin/jqhtml-ssr-example.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@jqhtml/ssr/node_modules/jsdom": {
"version": "24.1.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz",
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"license": "MIT",
"dependencies": {
"cssstyle": "^4.0.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.4.3",
"form-data": "^4.0.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.5",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.12",
"parse5": "^7.1.2",
"rrweb-cssom": "^0.7.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^4.1.4",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^2.11.2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/@jqhtml/ssr/node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@jqhtml/ssr/node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"license": "MIT"
},
"node_modules/@jqhtml/ssr/node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@jqhtml/ssr/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/@jqhtml/vscode-extension": { "node_modules/@jqhtml/vscode-extension": {
"version": "2.3.23", "version": "2.3.26",
"resolved": "http://privatenpm.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.23.tgz", "resolved": "http://privatenpm.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.26.tgz",
"integrity": "sha512-3yMYYhEm+kt8BqCs5YUZq+hwZdZdqiIIb2iMltISw84WTFGiN2LRzQT/8K7InNfs6T6CyKNfWSfb17W9MAj1Zg==", "integrity": "sha512-gXlt7e5hIo7y0RVGboWtPPeR/nXPD/fwBO/X2nMkimZXViEsH2hMXDbpIrfjN22tFFqet1o4xYWTHmYV2y/Oew==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"vscode": "^1.74.0" "vscode": "^1.74.0"
@@ -2617,9 +2713,9 @@
} }
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3", "version": "4.53.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.4.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "integrity": "sha512-jjEMkzvASQBbzzlzf4os7nzSBd/cvPrpqXCUOqoeCh1dQ4BP3RZCJk8XBeik4MUln3m+8LeTJcY54C/u8wb3DQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3012,9 +3108,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.26", "version": "20.19.27",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
"integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -3711,9 +3807,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.22", "version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -3730,10 +3826,9 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.27.0", "browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001760",
"fraction.js": "^5.3.4", "fraction.js": "^5.3.4",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0" "postcss-value-parser": "^4.2.0"
}, },
@@ -8998,15 +9093,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-url": { "node_modules/normalize-url": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@@ -10217,6 +10303,27 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/psl/node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/public-encrypt": { "node_modules/public-encrypt": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
@@ -10282,6 +10389,12 @@
"node": ">=0.4.x" "node": ">=0.4.x"
} }
}, },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -10677,9 +10790,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.53.3", "version": "4.53.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.4.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "integrity": "sha512-YpXaaArg0MvrnJpvduEDYIp7uGOqKXbH9NsHGQ6SxKCOsNAjZF018MmxefFUulVP2KLtiGw1UvZbr+/ekjvlDg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@@ -10692,28 +10805,28 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm-eabi": "4.53.4",
"@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-android-arm64": "4.53.4",
"@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.4",
"@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.4",
"@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.4",
"@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.4",
"@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.4",
"@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.4",
"@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.4",
"@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.4",
"@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.4",
"@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.4",
"@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.4",
"@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.4",
"@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.4",
"@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.4",
"@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.4",
"@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.4",
"@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.4",
"@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.4",
"@rollup/rollup-win32-x64-msvc": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.4",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -10945,9 +11058,9 @@
} }
}, },
"node_modules/send": { "node_modules/send": {
"version": "0.19.1", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "2.6.9", "debug": "2.6.9",
@@ -10956,13 +11069,13 @@
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"etag": "~1.8.1", "etag": "~1.8.1",
"fresh": "0.5.2", "fresh": "~0.5.2",
"http-errors": "2.0.0", "http-errors": "~2.0.1",
"mime": "1.6.0", "mime": "1.6.0",
"ms": "2.1.3", "ms": "2.1.3",
"on-finished": "2.4.1", "on-finished": "~2.4.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"statuses": "2.0.1" "statuses": "~2.0.2"
}, },
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
@@ -10983,31 +11096,6 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/send/node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
@@ -11096,93 +11184,20 @@
} }
}, },
"node_modules/serve-static": { "node_modules/serve-static": {
"version": "1.16.2", "version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"send": "0.19.0" "send": "~0.19.1"
}, },
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/serve-static/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/serve-static/node_modules/debug/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/serve-static/node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/serve-static/node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/serve-static/node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/serve-static/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -12341,6 +12356,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/util": { "node_modules/util": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",

View File

@@ -77,7 +77,14 @@ class LifecycleManager {
// Skip entirely if component uses default stub to avoid unnecessary yield // Skip entirely if component uses default stub to avoid unnecessary yield
if (component._has_on_load()) { if (component._has_on_load()) {
await component._load(); await component._load();
// Yield to microtask queue after on_load completes
// This ensures async queue tasks always run before the on_load process completes,
// even if on_load() itself completes instantly (deterministic behavior)
await Promise.resolve();
} }
// Trigger 'loaded' event - fires after on_load completes (or would have completed)
// This happens before the render/on_render second step
component.trigger('loaded');
// Check if stopped during load // Check if stopped during load
if (component._stopped) if (component._stopped)
return; return;
@@ -4845,7 +4852,7 @@ function init(jQuery) {
} }
} }
// Version - will be replaced during build with actual version from package.json // Version - will be replaced during build with actual version from package.json
const version = '2.3.23'; const version = '2.3.26';
// Default export with all functionality // Default export with all functionality
const jqhtml = { const jqhtml = {
// Core // Core

File diff suppressed because one or more lines are too long

View File

@@ -73,7 +73,14 @@ class LifecycleManager {
// Skip entirely if component uses default stub to avoid unnecessary yield // Skip entirely if component uses default stub to avoid unnecessary yield
if (component._has_on_load()) { if (component._has_on_load()) {
await component._load(); await component._load();
// Yield to microtask queue after on_load completes
// This ensures async queue tasks always run before the on_load process completes,
// even if on_load() itself completes instantly (deterministic behavior)
await Promise.resolve();
} }
// Trigger 'loaded' event - fires after on_load completes (or would have completed)
// This happens before the render/on_render second step
component.trigger('loaded');
// Check if stopped during load // Check if stopped during load
if (component._stopped) if (component._stopped)
return; return;
@@ -4841,7 +4848,7 @@ function init(jQuery) {
} }
} }
// Version - will be replaced during build with actual version from package.json // Version - will be replaced during build with actual version from package.json
const version = '2.3.23'; const version = '2.3.26';
// Default export with all functionality // Default export with all functionality
const jqhtml = { const jqhtml = {
// Core // Core

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
/** /**
* JQHTML Core v2.3.23 * JQHTML Core v2.3.26
* (c) 2025 JQHTML Team * (c) 2025 JQHTML Team
* Released under the MIT License * Released under the MIT License
*/ */
@@ -78,7 +78,14 @@ class LifecycleManager {
// Skip entirely if component uses default stub to avoid unnecessary yield // Skip entirely if component uses default stub to avoid unnecessary yield
if (component._has_on_load()) { if (component._has_on_load()) {
await component._load(); await component._load();
// Yield to microtask queue after on_load completes
// This ensures async queue tasks always run before the on_load process completes,
// even if on_load() itself completes instantly (deterministic behavior)
await Promise.resolve();
} }
// Trigger 'loaded' event - fires after on_load completes (or would have completed)
// This happens before the render/on_render second step
component.trigger('loaded');
// Check if stopped during load // Check if stopped during load
if (component._stopped) if (component._stopped)
return; return;
@@ -4846,7 +4853,7 @@ function init(jQuery) {
} }
} }
// Version - will be replaced during build with actual version from package.json // Version - will be replaced during build with actual version from package.json
const version = '2.3.23'; const version = '2.3.26';
// Default export with all functionality // Default export with all functionality
const jqhtml = { const jqhtml = {
// Core // Core

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"lifecycle-manager.d.ts","sourceRoot":"","sources":["../src/lifecycle-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEvD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAmB;IAC1C,OAAO,CAAC,iBAAiB,CAAoC;IAE7D,MAAM,CAAC,YAAY,IAAI,gBAAgB;;IAevC;;;;;;;;;OASG;IACG,cAAc,CAAC,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IA+GhE;;OAEG;IACH,oBAAoB,CAAC,SAAS,EAAE,gBAAgB,GAAG,IAAI;IAIvD;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;CAetC"} {"version":3,"file":"lifecycle-manager.d.ts","sourceRoot":"","sources":["../src/lifecycle-manager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAEvD,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAmB;IAC1C,OAAO,CAAC,iBAAiB,CAAoC;IAE7D,MAAM,CAAC,YAAY,IAAI,gBAAgB;;IAevC;;;;;;;;;OASG;IACG,cAAc,CAAC,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAwHhE;;OAEG;IACH,oBAAoB,CAAC,SAAS,EAAE,gBAAgB,GAAG,IAAI;IAIvD;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;CAetC"}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@jqhtml/core", "name": "@jqhtml/core",
"version": "2.3.23", "version": "2.3.26",
"description": "Core runtime library for JQHTML", "description": "Core runtime library for JQHTML",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@@ -1377,7 +1377,7 @@ export class CodeGenerator {
for (const [name, component] of this.components) { for (const [name, component] of this.components) {
code += `// Component: ${name}\n`; code += `// Component: ${name}\n`;
code += `jqhtml_components.set('${name}', {\n`; code += `jqhtml_components.set('${name}', {\n`;
code += ` _jqhtml_version: '2.3.23',\n`; // Version will be replaced during build code += ` _jqhtml_version: '2.3.26',\n`; // Version will be replaced during build
code += ` name: '${name}',\n`; code += ` name: '${name}',\n`;
code += ` tag: '${component.tagName}',\n`; code += ` tag: '${component.tagName}',\n`;
code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`; code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@jqhtml/parser", "name": "@jqhtml/parser",
"version": "2.3.23", "version": "2.3.26",
"description": "JQHTML template parser - converts templates to JavaScript", "description": "JQHTML template parser - converts templates to JavaScript",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

114
node_modules/@jqhtml/ssr/QUICKSTART.md generated vendored Normal file
View File

@@ -0,0 +1,114 @@
# JQHTML SSR Quickstart
## 1. Configure npm Registry
Create `.npmrc` in your project root:
```
@jqhtml:registry=https://privatenpm.hanson.xyz/
//privatenpm.hanson.xyz/:_auth=anFodG1sOkFHbTMyNStFdklOQXdUMnN0S0g2cXc9PQ==
```
## 2. Install
```bash
npm install @jqhtml/ssr
```
## 3. Start the Server
```bash
# TCP (for remote/container access)
npx jqhtml-ssr --tcp 9876
# Or Unix socket (better performance, local only)
npx jqhtml-ssr --socket /tmp/jqhtml-ssr.sock
```
## 4. Test with Reference CLI
```bash
npx jqhtml-ssr-example \
--vendor ./path/to/vendor-bundle.js \
--app ./path/to/app-bundle.js \
--component Dashboard_Index_Action \
--base-url http://localhost:8000
```
## 5. Integration
### Protocol
Send newline-delimited JSON to the server:
```json
{
"id": "req-1",
"type": "render",
"payload": {
"bundles": [
{ "id": "vendor", "content": "<vendor bundle JS>" },
{ "id": "app", "content": "<app bundle JS>" }
],
"component": "Component_Name",
"args": {},
"options": {
"baseUrl": "http://localhost:8000",
"timeout": 30000
}
}
}
```
Response:
```json
{
"id": "req-1",
"status": "success",
"payload": {
"html": "<div class=\"Component_Name Component\">...</div>",
"cache": {
"localStorage": {},
"sessionStorage": {}
},
"timing": {
"total_ms": 230,
"bundle_load_ms": 50,
"render_ms": 100
}
}
}
```
### PHP Example
```php
<?php
$socket = stream_socket_client('unix:///tmp/jqhtml-ssr.sock');
$request = json_encode([
'id' => uniqid(),
'type' => 'render',
'payload' => [
'bundles' => [
['id' => 'vendor', 'content' => file_get_contents('vendor.js')],
['id' => 'app', 'content' => file_get_contents('app.js')]
],
'component' => 'Dashboard_Index_Action',
'args' => [],
'options' => ['baseUrl' => 'http://localhost', 'timeout' => 30000]
]
]) . "\n";
fwrite($socket, $request);
$response = json_decode(fgets($socket), true);
echo $response['payload']['html'];
```
## Documentation
- `README.md` - Full integration guide
- `SPECIFICATION.md` - Protocol specification
- `bin/jqhtml-ssr-example.js` - Reference implementation (source of truth)

464
node_modules/@jqhtml/ssr/README.md generated vendored Normal file
View File

@@ -0,0 +1,464 @@
# JQHTML Server-Side Rendering (SSR)
> **See [SPECIFICATION.md](./SPECIFICATION.md) for the complete technical specification.**
## Overview
The JQHTML SSR system renders components to HTML on the server for SEO purposes. Unlike React/Vue/Angular which require separate data-fetching abstractions, JQHTML SSR runs the exact same component code - including `on_load()` with real HTTP requests.
**Primary use case:** SEO - rendering meaningful HTML for search engine crawlers.
## Installation
```bash
npm install @jqhtml/ssr
```
Or install globally for CLI access:
```bash
npm install -g @jqhtml/ssr
```
---
## Quick Start
### Starting the Server
```bash
cd /var/www/html/jqhtml/aux/ssr
npm install
# Start on TCP port
node src/server.js --tcp 9876
# Or with Unix socket (better performance, local only)
node src/server.js --socket /tmp/jqhtml-ssr.sock
```
### Server Options
```
--tcp <port> Listen on TCP port
--socket <path> Listen on Unix socket
--max-bundles <n> Max cached bundle sets (default: 10)
--timeout <ms> Default render timeout (default: 30000)
--help Show help
```
---
## Reference CLI Example
The package includes `jqhtml-ssr-example`, a complete reference implementation that demonstrates the full SSR workflow. **Use this as the canonical source of truth for building integrations in any language.**
### Basic Usage
```bash
# Start the SSR server in one terminal
jqhtml-ssr --tcp 9876
# In another terminal, run the example
jqhtml-ssr-example \
--vendor ./bundles/vendor.js \
--app ./bundles/app.js \
--component Dashboard_Index_Action \
--base-url http://localhost:3000
```
### Example Options
```
REQUIRED:
--vendor, -v <path> Path to vendor bundle (contains @jqhtml/core)
--app, -a <path> Path to app bundle (contains components)
--component, -c <name> Component name to render
OPTIONS:
--args <json> Component arguments as JSON (default: {})
--base-url, -b <url> Base URL for fetch requests (default: http://localhost:3000)
--timeout, -t <ms> Render timeout in milliseconds (default: 30000)
--port, -p <port> SSR server port (default: 9876)
--socket, -s <path> Use Unix socket instead of TCP
--format, -f <format> Output format: pretty, json, html-only (default: pretty)
--help, -h Show help message
```
### Output Formats
**Pretty (default)** - Human-readable output showing:
- Rendered HTML (formatted)
- localStorage cache entries
- sessionStorage cache entries
- Timing information
```bash
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component
```
**JSON** - Raw server response for programmatic use:
```bash
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component --format json > result.json
```
**HTML-only** - Just the rendered HTML:
```bash
jqhtml-ssr-example -v vendor.js -a app.js -c My_Component --format html-only > output.html
```
### Example with Component Arguments
```bash
jqhtml-ssr-example \
-v ./bundles/vendor.js \
-a ./bundles/app.js \
-c User_Profile \
--args '{"user_id": 123, "show_avatar": true}'
```
### Integration Reference
The `jqhtml-ssr-example` source code (`bin/jqhtml-ssr-example.js`) is the **canonical reference** for building integrations. Key implementation details:
1. **Connection** - TCP socket to `localhost:PORT` or Unix socket
2. **Request format** - Newline-delimited JSON
3. **Bundle loading** - Read JS files, send as `content` strings
4. **Request structure** - See the commented request object in the source
5. **Response parsing** - JSON with `status`, `payload`, and `error` fields
When building integrations in PHP, Python, Go, etc., refer to this example for the exact protocol and data structures.
---
## Integration
### Protocol
The server uses a simple newline-delimited JSON protocol over TCP or Unix socket.
**Request format:**
```json
{
"id": "unique-request-id",
"type": "render",
"payload": {
"bundles": [
{ "id": "vendor", "content": "..." },
{ "id": "app", "content": "..." }
],
"component": "Dashboard_Index_Action",
"args": { "user_id": 123 },
"options": {
"baseUrl": "http://localhost:3000",
"timeout": 30000
}
}
}
```
**Response format:**
```json
{
"id": "unique-request-id",
"status": "success",
"payload": {
"html": "<div class=\"Dashboard_Index_Action Component\">...</div>",
"cache": {
"localStorage": {},
"sessionStorage": {}
},
"timing": {
"total_ms": 231,
"bundle_load_ms": 49,
"render_ms": 99
}
}
}
```
### PHP Integration Example
```php
<?php
class JqhtmlSSR {
private $socket;
public function __construct(string $socketPath = '/tmp/jqhtml-ssr.sock') {
$this->socket = stream_socket_client("unix://$socketPath", $errno, $errstr, 5);
if (!$this->socket) {
throw new Exception("SSR connection failed: $errstr");
}
}
public function render(string $component, array $args = [], array $bundles = []): array {
$request = json_encode([
'id' => uniqid('ssr-'),
'type' => 'render',
'payload' => [
'bundles' => $bundles,
'component' => $component,
'args' => $args,
'options' => [
'baseUrl' => 'http://localhost',
'timeout' => 30000
]
]
]) . "\n";
fwrite($this->socket, $request);
$response = fgets($this->socket);
return json_decode($response, true);
}
public function ping(): bool {
$request = json_encode([
'id' => 'ping',
'type' => 'ping',
'payload' => []
]) . "\n";
fwrite($this->socket, $request);
$response = json_decode(fgets($this->socket), true);
return $response['status'] === 'success';
}
}
// Usage
$ssr = new JqhtmlSSR();
$result = $ssr->render('Dashboard_Index_Action', ['user_id' => 123], $bundles);
if ($result['status'] === 'success') {
echo $result['payload']['html'];
}
```
### Node.js Client Example
```javascript
const net = require('net');
function sendSSRRequest(port, request) {
return new Promise((resolve, reject) => {
const client = net.createConnection({ port }, () => {
client.write(JSON.stringify(request) + '\n');
});
let data = '';
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('\n')) {
client.end();
resolve(JSON.parse(data.trim()));
}
});
client.on('error', reject);
setTimeout(() => {
client.end();
reject(new Error('Request timeout'));
}, 30000);
});
}
// Usage
const response = await sendSSRRequest(9876, {
id: 'render-1',
type: 'render',
payload: {
bundles: [
{ id: 'vendor', content: vendorBundleContent },
{ id: 'app', content: appBundleContent }
],
component: 'Dashboard_Index_Action',
args: { user_id: 123 },
options: { baseUrl: 'http://localhost:3000' }
}
});
console.log(response.payload.html);
```
---
## Request Types
### ping
Health check.
```json
{ "id": "1", "type": "ping", "payload": {} }
```
Response includes `uptime_ms`.
### render
Render a component to HTML.
Required payload fields:
- `bundles` - Array of `{ id, content }` objects
- `component` - Component name to render
- `options.baseUrl` - Base URL for relative fetch/XHR requests
Optional:
- `args` - Component arguments (default: `{}`)
- `options.timeout` - Render timeout in ms (default: 30000)
### flush_cache
Clear bundle cache.
```json
{ "id": "1", "type": "flush_cache", "payload": {} }
```
Or flush specific bundle:
```json
{ "id": "1", "type": "flush_cache", "payload": { "bundle_id": "app" } }
```
---
## Error Codes
| Code | Description |
|------|-------------|
| `PARSE_ERROR` | Invalid JSON or malformed request |
| `BUNDLE_ERROR` | JavaScript syntax error in bundle |
| `COMPONENT_NOT_FOUND` | Component not registered after loading bundles |
| `RENDER_ERROR` | Error during component lifecycle |
| `RENDER_TIMEOUT` | Component did not reach ready state in time |
| `INTERNAL_ERROR` | Unexpected server error |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ SSR Server (long-running Node.js process) │
│ │
│ 1. Receive request (component name, args, bundles) │
│ 2. Load bundles (cached) into isolated jsdom environment │
│ 3. Execute component lifecycle (including real fetch()) │
│ 4. Wait for on_ready │
│ 5. Return HTML + localStorage/sessionStorage cache dump │
└─────────────────────────────────────────────────────────────────┘
```
**Key features:**
- **Real data fetching** - Components make actual HTTP requests during SSR
- **Cache export** - Server exports storage state for instant client hydration
- **Bundle caching** - Prepared bundle code cached with LRU eviction
- **Request isolation** - Each render gets fresh jsdom environment
- **URL rewriting** - Relative URLs resolved against baseUrl
---
## File Structure
```
aux/ssr/
├── SPECIFICATION.md # Complete technical specification
├── README.md # This file
├── package.json
├── bin/
│ └── jqhtml-ssr-example.js # Reference CLI example (canonical integration source)
├── src/
│ ├── index.js # Package exports
│ ├── server.js # TCP/Unix socket server (jqhtml-ssr CLI)
│ ├── environment.js # jsdom + jQuery environment setup
│ ├── bundle-cache.js # Bundle caching with LRU eviction
│ ├── http-intercept.js # fetch/XHR URL rewriting
│ ├── storage.js # Fake localStorage/sessionStorage
│ └── protocol.js # Message parsing/formatting
├── test/
│ ├── test-protocol.js # Protocol unit tests (16 tests)
│ ├── test-storage.js # Storage unit tests (13 tests)
│ └── test-server.js # Server integration tests (6 tests)
├── test-manual.js # Manual test with real bundles
└── test-debug.js # Debug script for troubleshooting
```
---
## Running Tests
```bash
# Unit tests
node test/test-protocol.js
node test/test-storage.js
node test/test-server.js
# Manual test with real bundles (requires bundles_for_ssr_test/)
node test-manual.js
```
---
## Critical Technical Discoveries
### 1. jQuery Module Load Order (CRITICAL)
**jQuery MUST be `require()`'d BEFORE `global.window` is set.**
```javascript
// CORRECT - jQuery returns a factory function
const jqueryFactory = require('jquery'); // First!
global.window = jsdomWindow; // Second
const $ = jqueryFactory(jsdomWindow); // Returns working jQuery
// WRONG - jQuery auto-initializes and returns an object
global.window = jsdomWindow; // First (BAD)
const jqueryFactory = require('jquery'); // jQuery sees window, auto-binds
const $ = jqueryFactory(jsdomWindow); // Returns object, not function!
```
**Why:** jQuery checks for `global.window` at require-time. If it exists, jQuery auto-initializes against that window instead of returning a factory.
### 2. Use `vm.runInThisContext` Not `vm.createContext`
Using `vm.createContext` creates VM context boundary issues where function references don't work across contexts. jQuery wrapper functions fail because closures can't access variables across the boundary.
**Solution:** Use `vm.runInThisContext` which executes in Node's global context.
### 3. Bundle Loading Order
Load bundles in dependency order:
1. **Vendor bundle** - Contains `@jqhtml/core`, initializes `window.jqhtml`
2. **App bundle** - Contains templates and component classes
### 4. Global Cleanup
When destroying environments, set globals to `undefined` instead of using `delete`:
```javascript
global.window = undefined; // Safe
// delete global.window; // Can cause issues
```
---
## Dependencies
```json
{
"jsdom": "^24.0.0",
"jquery": "^3.7.1"
}
```
---
## References
- [SPECIFICATION.md](./SPECIFICATION.md) - Complete technical specification
- [/packages/core/src/component.ts](/packages/core/src/component.ts) - Component lifecycle
- [/docs/official/18_boot.md](/docs/official/18_boot.md) - Client-side boot/hydration
- [jsdom documentation](https://github.com/jsdom/jsdom)
- [jQuery in Node.js](https://www.npmjs.com/package/jquery)

620
node_modules/@jqhtml/ssr/SPECIFICATION.md generated vendored Normal file
View File

@@ -0,0 +1,620 @@
# JQHTML Server-Side Rendering (SSR) Specification
> **Document Status:** Active specification for `@jqhtml/ssr` package development.
## Overview
The JQHTML SSR system provides server-side rendering of JQHTML components via a persistent socket server. Unlike React/Vue/Angular SSR which require separate data-fetching abstractions, JQHTML SSR runs the exact same component code on the server - including `on_load()` with real HTTP requests.
**Primary use case:** SEO - rendering meaningful HTML for search engine crawlers.
**Architecture:** Long-running Node.js process accepting requests via TCP or Unix socket.
---
## Design Principles
1. **Same code path** - Components run identically on server and client
2. **Real data fetching** - `on_load()` makes actual HTTP requests during SSR
3. **Cache export** - Server exports localStorage/sessionStorage state for client hydration
4. **Request isolation** - Each render request has completely fresh state
5. **Bundle caching** - Parsed bundles stay in memory; environment state is cloned per request
6. **No authentication** - SSR is for SEO; crawlers aren't authenticated
---
## Server Architecture
### Process Model
```
┌─────────────────────────────────────────────────────────────────┐
│ SSR Server Process (long-running) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Bundle Cache │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Bundle Set A│ │ Bundle Set B│ │ Bundle Set C│ │ │
│ │ │ (hash: abc) │ │ (hash: def) │ │ (hash: ghi) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Request Handler │ │
│ │ │ │
│ │ Request → Create Isolated Environment │ │
│ │ → Load Bundle Set (from cache or parse) │ │
│ │ → Execute Component Lifecycle │ │
│ │ → Capture HTML + Cache State │ │
│ │ → Return Response │ │
│ │ → Destroy Environment │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Bundle Caching Strategy
Bundles are identified by a hash of their contents (or explicit version string). When a request comes in:
1. Check if bundle set exists in cache by identifier
2. If cached: clone the parsed bundle environment
3. If not cached: parse bundles, store in cache, then clone
**Cache eviction:** LRU with configurable max entries (default: 10 bundle sets).
### Request Isolation
Each render request gets:
- Fresh jsdom Document
- Fresh fake localStorage/sessionStorage
- Fresh global state (window.jqhtml, etc.)
- Cloned bundle execution context
This ensures one request cannot affect another, even if component code modifies global state.
---
## Protocol Specification
### Transport
Support two transport modes:
- **TCP:** For network access (e.g., PHP/Laravel calling from different process)
- **Unix Socket:** For local IPC (lower latency, better security)
### Message Format
JSON-based protocol with newline-delimited messages.
#### Request Format
```json
{
"id": "request-uuid-123",
"type": "render",
"payload": {
"bundles": [
{
"id": "vendor-abc123",
"content": "... javascript content ..."
},
{
"id": "app-def456",
"content": "... javascript content ..."
}
],
"component": "Dashboard_Index",
"args": {
"page": 1,
"filter": "active"
},
"options": {
"baseUrl": "https://example.com",
"timeout": 30000
}
}
}
```
**Fields:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Unique request identifier for correlation |
| `type` | string | Yes | Request type: `"render"`, `"ping"`, `"flush_cache"` |
| `payload.bundles` | array | Yes | JavaScript bundles to load |
| `payload.bundles[].id` | string | Yes | Bundle identifier (for caching) |
| `payload.bundles[].content` | string | Yes | JavaScript source code |
| `payload.component` | string | Yes | Component name to render |
| `payload.args` | object | No | Arguments to pass to component |
| `payload.options.baseUrl` | string | Yes | Base URL for relative fetch/ajax requests |
| `payload.options.timeout` | number | No | Max render time in ms (default: 30000) |
#### Response Format
**Success Response:**
```json
{
"id": "request-uuid-123",
"status": "success",
"payload": {
"html": "<div class=\"Dashboard_Index Component\">...</div>",
"cache": {
"localStorage": {
"jqhtml_cache:Dashboard_Index:{\"page\":1}": "{\"items\":[...],\"total\":42}"
},
"sessionStorage": {}
},
"timing": {
"total_ms": 245,
"bundle_load_ms": 12,
"render_ms": 233
}
}
}
```
**Error Response:**
```json
{
"id": "request-uuid-123",
"status": "error",
"error": {
"code": "RENDER_TIMEOUT",
"message": "Component render exceeded 30000ms timeout",
"stack": "..."
}
}
```
**Error Codes:**
| Code | HTTP Equivalent | Description |
|------|-----------------|-------------|
| `PARSE_ERROR` | 400 | Invalid request JSON |
| `BUNDLE_ERROR` | 400 | JavaScript bundle failed to parse/execute |
| `COMPONENT_NOT_FOUND` | 404 | Requested component not registered |
| `RENDER_ERROR` | 500 | Component threw during lifecycle |
| `RENDER_TIMEOUT` | 504 | Render exceeded timeout |
| `INTERNAL_ERROR` | 500 | Unexpected server error |
#### Other Request Types
**Ping (health check):**
```json
{"id": "ping-1", "type": "ping", "payload": {}}
```
Response:
```json
{"id": "ping-1", "status": "success", "payload": {"uptime_ms": 123456}}
```
**Flush cache:**
```json
{"id": "flush-1", "type": "flush_cache", "payload": {"bundle_id": "vendor-abc123"}}
```
Response:
```json
{"id": "flush-1", "status": "success", "payload": {"flushed": true}}
```
---
## URL Rewriting / HTTP Interception
Components make HTTP requests via `fetch()` or jQuery's `$.ajax()`. These need URL rewriting for server-side execution.
### Interception Points
1. **`global.fetch`** - Override with custom implementation
2. **`XMLHttpRequest`** - Override for jQuery compatibility
### URL Rewriting Rules
Given `baseUrl: "https://example.com"`:
| Original | Rewritten |
|----------|-----------|
| `/api/users` | `https://example.com/api/users` |
| `//cdn.example.com/data.json` | `https://cdn.example.com/data.json` |
| `https://other.com/api` | `https://other.com/api` (unchanged) |
| `api/users` (relative) | `https://example.com/api/users` |
### Implementation
```javascript
// Pseudocode for fetch override
const originalFetch = globalThis.fetch;
globalThis.fetch = function(url, options) {
const resolvedUrl = resolveUrl(url, baseUrl);
return originalFetch(resolvedUrl, {
...options,
// Strip any auth headers for SEO safety
headers: filterHeaders(options?.headers)
});
};
```
jQuery uses XMLHttpRequest internally, so overriding XHR covers `$.ajax()`, `$.get()`, `$.post()`.
---
## JQHTML Core Modifications
### SSR Mode Flag
Add `ssr_mode` configuration to jqhtml initialization:
```javascript
// In SSR environment, before loading bundles:
window.__JQHTML_SSR_MODE__ = true;
// Or via jqhtml config:
jqhtml.configure({ ssr_mode: true });
```
### Fake Storage Implementation
When `ssr_mode` is true, replace `localStorage` and `sessionStorage` with capturing implementations:
```javascript
class SSR_Storage {
constructor() {
this._data = new Map();
}
getItem(key) {
return this._data.get(key) ?? null;
}
setItem(key, value) {
this._data.set(key, String(value));
}
removeItem(key) {
this._data.delete(key);
}
clear() {
this._data.clear();
}
get length() {
return this._data.size;
}
key(index) {
return Array.from(this._data.keys())[index] ?? null;
}
// SSR-specific: export all data
_export() {
return Object.fromEntries(this._data);
}
}
```
### Cache Export
At end of SSR render, export storage state:
```javascript
function exportCacheState() {
return {
localStorage: window.localStorage._export(),
sessionStorage: window.sessionStorage._export()
};
}
```
### Behaviors Disabled in SSR Mode
When `ssr_mode` is true:
| Feature | Behavior |
|---------|----------|
| Event handlers (`@click`, etc.) | Rendered as attributes but not bound |
| `setInterval`/`setTimeout` | Allowed but auto-cleared after render |
| `requestAnimationFrame` | Stubbed (no-op) |
| CSS animations | Ignored |
| `window.location` mutations | Blocked with warning |
---
## Client-Side Hydration
The SSR server returns HTML and cache state. Client-side integration is the responsibility of the external tooling, but the expected pattern is:
### 1. Server Renders Page with SSR HTML
```html
<div id="app">
<!-- SSR-rendered component HTML -->
<div class="Dashboard_Index Component" data-cid="abc123">
<h1>Dashboard</h1>
<div class="User_List Component" data-cid="def456">
<ul>
<li>User 1</li>
<li>User 2</li>
</ul>
</div>
</div>
</div>
<script type="application/json" id="jqhtml-ssr-cache">
{
"localStorage": {
"jqhtml_cache:Dashboard_Index:{...}": "...",
"jqhtml_cache:User_List:{...}": "..."
},
"sessionStorage": {}
}
</script>
```
### 2. Client JavaScript Hydrates
```javascript
// 1. Load cache data into storage BEFORE any components initialize
const cacheData = JSON.parse(document.getElementById('jqhtml-ssr-cache').textContent);
for (const [key, value] of Object.entries(cacheData.localStorage)) {
localStorage.setItem(key, value);
}
for (const [key, value] of Object.entries(cacheData.sessionStorage)) {
sessionStorage.setItem(key, value);
}
// 2. Replace SSR HTML with live components
// Option A: Clear and re-render (simple, guaranteed correct)
$('#app').empty();
$('#app').component('Dashboard_Index', { page: 1, filter: 'active' });
// Option B: Use boot() if SSR output used placeholder format
// jqhtml.boot($('#app'));
```
### Why Replace Instead of Attach?
JQHTML's model is simpler than React/Vue hydration:
1. SSR provides meaningful HTML for SEO crawlers
2. Client replaces with live components using cached data
3. Cache ensures `on_load()` returns instantly (cache hit)
4. No complex DOM diffing or event attachment needed
5. Single code path - same render logic everywhere
The "flicker" is avoided because:
- Cache is pre-populated before components initialize
- Components render synchronously from cache
- Browser paints once with final result
---
## Testing Strategy
### Unit Tests (Node.js)
Test SSR server components in isolation:
```
tests/ssr/
├── test-bundle-cache.js # Bundle caching logic
├── test-url-rewriting.js # Fetch/XHR URL rewriting
├── test-storage-capture.js # Fake localStorage/sessionStorage
├── test-isolation.js # Request isolation
├── test-protocol.js # Message parsing/formatting
└── test-timeout.js # Render timeout handling
```
### Integration Tests
Test full SSR render cycle:
```
tests/ssr_integration/
├── basic_render/
│ ├── test_component.jqhtml
│ ├── test_component.js
│ └── run-test.sh # Starts SSR server, sends request, verifies output
├── fetch_data/
│ ├── fetching_component.jqhtml
│ ├── fetching_component.js
│ ├── mock_server.js # Mock API server for fetch testing
│ └── run-test.sh
├── nested_components/
│ └── ...
├── cache_export/
│ └── ...
└── error_handling/
└── ...
```
### Test Runner Integration
Tests should work with existing `run-all-tests.sh`:
```bash
#!/bin/bash
# tests/ssr_integration/basic_render/run-test.sh
# Start SSR server in background
node /var/www/html/jqhtml/aux/ssr/server.js --port 9999 &
SSR_PID=$!
sleep 1
# Run test
node test-runner.js
# Capture result
RESULT=$?
# Cleanup
kill $SSR_PID
exit $RESULT
```
---
## File Structure
```
aux/ssr/
├── SPECIFICATION.md # This document
├── README.md # Quick start and API reference
├── package.json
├── src/
│ ├── server.js # TCP/Unix socket server
│ ├── renderer.js # SSR render orchestration
│ ├── environment.js # jsdom environment setup
│ ├── bundle-cache.js # Bundle caching with LRU
│ ├── http-intercept.js # fetch/XHR URL rewriting
│ ├── storage.js # Fake localStorage/sessionStorage
│ └── protocol.js # Message parsing/formatting
├── bin/
│ └── jqhtml-ssr # CLI entry point
└── test/
└── ...
```
---
## CLI Interface
```bash
# Start SSR server on TCP port
jqhtml-ssr --tcp 9876
# Start SSR server on Unix socket
jqhtml-ssr --socket /tmp/jqhtml-ssr.sock
# With options
jqhtml-ssr --tcp 9876 --max-bundles 20 --timeout 60000
# One-shot render (for testing)
jqhtml-ssr --render \
--bundle vendor.js \
--bundle app.js \
--component Dashboard_Index \
--args '{"page":1}' \
--base-url https://example.com
```
---
## Performance Considerations
### Bundle Caching
- Bundles are parsed once and cached by ID
- Environment state is cloned per request (not re-parsed)
- LRU eviction prevents unbounded memory growth
### Request Handling
- Requests processed concurrently (Node.js async I/O)
- Each request gets isolated environment
- Heavy renders don't block other requests
### Memory Management
- jsdom environments are destroyed after each request
- Weak references where possible for cache entries
- Configurable max concurrent renders
### Timeout Handling
- Each render has configurable timeout (default 30s)
- Timeout triggers environment destruction
- Partial renders are not returned
---
## Security Considerations
1. **No cookies/auth** - SSR requests don't forward authentication
2. **URL whitelist** - Only allow fetch to configured baseUrl domain
3. **Code execution** - Only execute provided bundles, no eval of request data
4. **Resource limits** - Timeout, memory limits per request
5. **Socket permissions** - Unix socket should have restricted permissions
---
## Future Considerations
### Streaming SSR
For large pages, stream HTML as it becomes available:
- Send opening tags immediately
- Stream component HTML as each completes
- Requires chunked transfer encoding
**Not in initial scope** - adds significant complexity.
### Partial Hydration
Only hydrate interactive components, leave static HTML as-is:
- Mark components as `static` or `interactive`
- Static components not replaced on client
**Not in initial scope** - current model is simpler.
### Worker Threads
For CPU-bound renders, use Node.js worker threads:
- Main thread handles I/O
- Workers execute renders
- Better CPU utilization
**Consider for v2** if single-threaded performance is insufficient.
---
## Implementation Phases
### Phase 1: Core SSR Server
- [ ] Protocol implementation (request/response parsing)
- [ ] Bundle loading and caching
- [ ] jsdom environment with SSR mode
- [ ] Basic render (create component, wait for ready, return HTML)
- [ ] Error handling and timeouts
### Phase 2: HTTP Interception
- [ ] fetch() override with URL rewriting
- [ ] XMLHttpRequest override for jQuery
- [ ] baseUrl configuration
### Phase 3: Cache Export
- [ ] Fake localStorage/sessionStorage implementation
- [ ] Cache state export in response
- [ ] Integration with jqhtml's cache system
### Phase 4: Testing
- [ ] Unit tests for each module
- [ ] Integration tests for full render cycle
- [ ] Performance benchmarks
- [ ] Test runner integration
### Phase 5: Documentation & Polish
- [ ] CLI help and examples
- [ ] Integration guide for Laravel/PHP
- [ ] Troubleshooting guide
---
## Appendix: Example Session
```
# Terminal 1: Start SSR server
$ jqhtml-ssr --tcp 9876
[SSR] Server listening on tcp://0.0.0.0:9876
# Terminal 2: Send render request
$ echo '{"id":"1","type":"render","payload":{"bundles":[{"id":"test","content":"..."}],"component":"Hello_World","args":{},"options":{"baseUrl":"http://localhost"}}}' | nc localhost 9876
{"id":"1","status":"success","payload":{"html":"<div class=\"Hello_World Component\">Hello, World!</div>","cache":{"localStorage":{},"sessionStorage":{}},"timing":{"total_ms":45}}}
```

467
node_modules/@jqhtml/ssr/bin/jqhtml-ssr-example.js generated vendored Executable file
View File

@@ -0,0 +1,467 @@
#!/usr/bin/env node
/**
* JQHTML SSR Reference Example CLI
*
* This is the canonical reference implementation for integrating with the
* JQHTML SSR server. Use this as a source of truth for external integrations.
*
* Usage:
* jqhtml-ssr-example --vendor <path> --app <path> --component <name> [options]
*
* Example:
* jqhtml-ssr-example \
* --vendor ./bundles/vendor.js \
* --app ./bundles/app.js \
* --component Dashboard_Index_Action \
* --base-url http://localhost:3000
*/
const net = require('net');
const fs = require('fs');
const path = require('path');
// ANSI colors for output
const colors = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
function color(text, c) {
return `${colors[c]}${text}${colors.reset}`;
}
/**
* Parse command line arguments
*/
function parseArgs() {
const args = process.argv.slice(2);
const config = {
vendor: null,
app: null,
component: null,
componentArgs: {},
baseUrl: 'http://localhost:3000',
timeout: 30000,
port: 9876,
socket: null,
outputFormat: 'pretty', // 'pretty', 'json', 'html-only'
showHelp: false
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--vendor':
case '-v':
config.vendor = args[++i];
break;
case '--app':
case '-a':
config.app = args[++i];
break;
case '--component':
case '-c':
config.component = args[++i];
break;
case '--args':
// Parse JSON args
try {
config.componentArgs = JSON.parse(args[++i]);
} catch (e) {
console.error(color('Error: --args must be valid JSON', 'red'));
process.exit(1);
}
break;
case '--base-url':
case '-b':
config.baseUrl = args[++i];
break;
case '--timeout':
case '-t':
config.timeout = parseInt(args[++i], 10);
break;
case '--port':
case '-p':
config.port = parseInt(args[++i], 10);
break;
case '--socket':
case '-s':
config.socket = args[++i];
break;
case '--format':
case '-f':
config.outputFormat = args[++i];
break;
case '--help':
case '-h':
config.showHelp = true;
break;
}
}
return config;
}
/**
* Show help message
*/
function showHelp() {
console.log(`
${color('JQHTML SSR Reference Example', 'bold')}
This CLI demonstrates the complete SSR workflow and serves as the
canonical reference for external integrations.
${color('USAGE:', 'yellow')}
jqhtml-ssr-example --vendor <path> --app <path> --component <name> [options]
${color('REQUIRED:', 'yellow')}
--vendor, -v <path> Path to vendor bundle (contains @jqhtml/core)
--app, -a <path> Path to app bundle (contains components)
--component, -c <name> Component name to render
${color('OPTIONS:', 'yellow')}
--args <json> Component arguments as JSON (default: {})
--base-url, -b <url> Base URL for fetch requests (default: http://localhost:3000)
--timeout, -t <ms> Render timeout in milliseconds (default: 30000)
--port, -p <port> SSR server port (default: 9876)
--socket, -s <path> Use Unix socket instead of TCP
--format, -f <format> Output format: pretty, json, html-only (default: pretty)
--help, -h Show this help message
${color('EXAMPLES:', 'yellow')}
# Basic render
jqhtml-ssr-example -v ./vendor.js -a ./app.js -c Dashboard_Index_Action
# With component arguments
jqhtml-ssr-example -v ./vendor.js -a ./app.js -c User_Profile \\
--args '{"user_id": 123}'
# JSON output for piping
jqhtml-ssr-example -v ./vendor.js -a ./app.js -c Dashboard_Index_Action \\
--format json > result.json
# HTML only output
jqhtml-ssr-example -v ./vendor.js -a ./app.js -c Dashboard_Index_Action \\
--format html-only > output.html
${color('OUTPUT:', 'yellow')}
The 'pretty' format shows:
- Rendered HTML (formatted)
- localStorage cache entries
- sessionStorage cache entries
- Timing information
The 'json' format outputs the raw server response.
The 'html-only' format outputs just the HTML.
${color('INTEGRATION NOTES:', 'yellow')}
This example demonstrates the exact protocol used by the SSR server.
See the source code for implementation details that can be adapted
to any language (PHP, Python, Ruby, Go, etc.).
Key integration points:
1. Connect to TCP port or Unix socket
2. Send newline-delimited JSON request
3. Read newline-delimited JSON response
4. Parse response and extract HTML + cache
${color('SEE ALSO:', 'yellow')}
README.md - Full documentation
SPECIFICATION.md - Protocol specification
`);
}
/**
* Connect to SSR server
* @param {object} config - Connection configuration
* @returns {Promise<net.Socket>}
*/
function connect(config) {
return new Promise((resolve, reject) => {
const options = config.socket
? { path: config.socket }
: { port: config.port, host: 'localhost' };
const client = net.createConnection(options, () => {
resolve(client);
});
client.on('error', (err) => {
if (err.code === 'ECONNREFUSED') {
reject(new Error(
`Cannot connect to SSR server at ${config.socket || `localhost:${config.port}`}\n` +
`Start the server with: node src/server.js --tcp ${config.port}`
));
} else {
reject(err);
}
});
});
}
/**
* Send request and receive response
* @param {net.Socket} client - Connected socket
* @param {object} request - Request object
* @param {number} timeout - Timeout in ms
* @returns {Promise<object>}
*/
function sendRequest(client, request, timeout) {
return new Promise((resolve, reject) => {
// Set timeout
const timer = setTimeout(() => {
client.end();
reject(new Error(`Request timeout after ${timeout}ms`));
}, timeout);
// Collect response
let data = '';
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('\n')) {
clearTimeout(timer);
client.end();
try {
resolve(JSON.parse(data.trim()));
} catch (e) {
reject(new Error(`Invalid response: ${data}`));
}
}
});
// Send request
// IMPORTANT: Request must end with newline for server to process
const requestStr = JSON.stringify(request) + '\n';
client.write(requestStr);
});
}
/**
* Load bundle file
* @param {string} filePath - Path to bundle file
* @returns {string} Bundle content
*/
function loadBundle(filePath) {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Bundle file not found: ${resolvedPath}`);
}
return fs.readFileSync(resolvedPath, 'utf8');
}
/**
* Format HTML for display (basic indentation)
* @param {string} html - Raw HTML
* @returns {string} Formatted HTML
*/
function formatHtml(html) {
// Simple formatting - indent nested tags
let formatted = '';
let indent = 0;
const lines = html
.replace(/></g, '>\n<')
.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Decrease indent for closing tags
if (trimmed.startsWith('</')) {
indent = Math.max(0, indent - 1);
}
formatted += ' '.repeat(indent) + trimmed + '\n';
// Increase indent for opening tags (not self-closing)
if (trimmed.startsWith('<') &&
!trimmed.startsWith('</') &&
!trimmed.endsWith('/>') &&
!trimmed.includes('</')) {
indent++;
}
}
return formatted;
}
/**
* Pretty print the result
* @param {object} response - Server response
* @param {object} config - Configuration
*/
function prettyPrint(response, config) {
console.log('\n' + color('═'.repeat(70), 'dim'));
console.log(color(' JQHTML SSR Render Result', 'bold'));
console.log(color('═'.repeat(70), 'dim'));
// Component info
console.log(`\n${color('Component:', 'cyan')} ${config.component}`);
if (Object.keys(config.componentArgs).length > 0) {
console.log(`${color('Arguments:', 'cyan')} ${JSON.stringify(config.componentArgs)}`);
}
// Status
if (response.status === 'success') {
console.log(`${color('Status:', 'cyan')} ${color('SUCCESS', 'green')}`);
} else {
console.log(`${color('Status:', 'cyan')} ${color('ERROR', 'red')}`);
console.log(`${color('Error Code:', 'red')} ${response.error.code}`);
console.log(`${color('Error Message:', 'red')} ${response.error.message}`);
if (response.error.stack) {
console.log(`${color('Stack:', 'dim')}\n${response.error.stack}`);
}
return;
}
// Timing
const timing = response.payload.timing;
console.log(`\n${color('Timing:', 'cyan')}`);
console.log(` Total: ${timing.total_ms}ms`);
console.log(` Bundle Load: ${timing.bundle_load_ms}ms`);
console.log(` Render: ${timing.render_ms}ms`);
// HTML
console.log(`\n${color('Rendered HTML:', 'cyan')} (${response.payload.html.length} bytes)`);
console.log(color('─'.repeat(70), 'dim'));
console.log(formatHtml(response.payload.html));
console.log(color('─'.repeat(70), 'dim'));
// Cache - localStorage
const localStorage = response.payload.cache.localStorage;
const localStorageKeys = Object.keys(localStorage);
console.log(`\n${color('localStorage Cache:', 'cyan')} (${localStorageKeys.length} entries)`);
if (localStorageKeys.length > 0) {
for (const key of localStorageKeys) {
const value = localStorage[key];
const preview = value.length > 100 ? value.substring(0, 100) + '...' : value;
console.log(` ${color(key, 'yellow')}: ${preview}`);
}
} else {
console.log(color(' (empty)', 'dim'));
}
// Cache - sessionStorage
const sessionStorage = response.payload.cache.sessionStorage;
const sessionStorageKeys = Object.keys(sessionStorage);
console.log(`\n${color('sessionStorage Cache:', 'cyan')} (${sessionStorageKeys.length} entries)`);
if (sessionStorageKeys.length > 0) {
for (const key of sessionStorageKeys) {
const value = sessionStorage[key];
const preview = value.length > 100 ? value.substring(0, 100) + '...' : value;
console.log(` ${color(key, 'yellow')}: ${preview}`);
}
} else {
console.log(color(' (empty)', 'dim'));
}
console.log('\n' + color('═'.repeat(70), 'dim') + '\n');
}
/**
* Main entry point
*/
async function main() {
const config = parseArgs();
if (config.showHelp) {
showHelp();
process.exit(0);
}
// Validate required arguments
if (!config.vendor || !config.app || !config.component) {
console.error(color('Error: --vendor, --app, and --component are required', 'red'));
console.error('Use --help for usage information');
process.exit(1);
}
try {
// Load bundles
console.log(color('Loading bundles...', 'dim'));
const vendorContent = loadBundle(config.vendor);
const appContent = loadBundle(config.app);
console.log(` Vendor: ${(vendorContent.length / 1024).toFixed(1)} KB`);
console.log(` App: ${(appContent.length / 1024).toFixed(1)} KB`);
// Connect to server
console.log(color('Connecting to SSR server...', 'dim'));
const client = await connect(config);
console.log(` Connected to ${config.socket || `localhost:${config.port}`}`);
// Build request
// This is the canonical request format - use this as reference for integrations
const request = {
// Unique request ID - use for correlating requests/responses
id: `render-${Date.now()}`,
// Request type: 'ping', 'render', or 'flush_cache'
type: 'render',
// Payload contains all render parameters
payload: {
// Bundles array - order matters! Vendor before app.
// Each bundle has 'id' (for caching) and 'content' (JS source)
bundles: [
{ id: 'vendor', content: vendorContent },
{ id: 'app', content: appContent }
],
// Component name to render (must be registered in bundles)
component: config.component,
// Arguments passed to component (becomes this.args)
args: config.componentArgs,
// Render options
options: {
// Base URL for resolving relative fetch/XHR URLs
baseUrl: config.baseUrl,
// Render timeout in milliseconds
timeout: config.timeout
}
}
};
// Send request and get response
console.log(color(`Rendering ${config.component}...`, 'dim'));
const response = await sendRequest(client, request, config.timeout + 5000);
// Output based on format
switch (config.outputFormat) {
case 'json':
console.log(JSON.stringify(response, null, 2));
break;
case 'html-only':
if (response.status === 'success') {
console.log(response.payload.html);
} else {
console.error(`Error: ${response.error.message}`);
process.exit(1);
}
break;
case 'pretty':
default:
prettyPrint(response, config);
break;
}
// Exit with appropriate code
process.exit(response.status === 'success' ? 0 : 1);
} catch (err) {
console.error(color(`Error: ${err.message}`, 'red'));
process.exit(1);
}
}
main();

22
node_modules/@jqhtml/ssr/node_modules/jsdom/LICENSE.txt generated vendored Executable file
View File

@@ -0,0 +1,22 @@
Copyright (c) 2010 Elijah Insua
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

521
node_modules/@jqhtml/ssr/node_modules/jsdom/README.md generated vendored Executable file
View File

@@ -0,0 +1,521 @@
<h1 align="center">
<img width="100" height="100" src="logo.svg" alt=""><br>
jsdom
</h1>
jsdom is a pure-JavaScript implementation of many web standards, notably the WHATWG [DOM](https://dom.spec.whatwg.org/) and [HTML](https://html.spec.whatwg.org/multipage/) Standards, for use with Node.js. In general, the goal of the project is to emulate enough of a subset of a web browser to be useful for testing and scraping real-world web applications.
The latest versions of jsdom require Node.js v18 or newer. (Versions of jsdom below v23 still work with previous Node.js versions, but are unsupported.)
## Basic usage
```js
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
```
To use jsdom, you will primarily use the `JSDOM` constructor, which is a named export of the jsdom main module. Pass the constructor a string. You will get back a `JSDOM` object, which has a number of useful properties, notably `window`:
```js
const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
console.log(dom.window.document.querySelector("p").textContent); // "Hello world"
```
(Note that jsdom will parse the HTML you pass it just like a browser does, including implied `<html>`, `<head>`, and `<body>` tags.)
The resulting object is an instance of the `JSDOM` class, which contains a number of useful properties and methods besides `window`. In general, it can be used to act on the jsdom from the "outside," doing things that are not possible with the normal DOM APIs. For simple cases, where you don't need any of this functionality, we recommend a coding pattern like
```js
const { window } = new JSDOM(`...`);
// or even
const { document } = (new JSDOM(`...`)).window;
```
Full documentation on everything you can do with the `JSDOM` class is below, in the section "`JSDOM` Object API".
## Customizing jsdom
The `JSDOM` constructor accepts a second parameter which can be used to customize your jsdom in the following ways.
### Simple options
```js
const dom = new JSDOM(``, {
url: "https://example.org/",
referrer: "https://example.com/",
contentType: "text/html",
includeNodeLocations: true,
storageQuota: 10000000
});
```
- `url` sets the value returned by `window.location`, `document.URL`, and `document.documentURI`, and affects things like resolution of relative URLs within the document and the same-origin restrictions and referrer used while fetching subresources. It defaults to `"about:blank"`.
- `referrer` just affects the value read from `document.referrer`. It defaults to no referrer (which reflects as the empty string).
- `contentType` affects the value read from `document.contentType`, as well as how the document is parsed: as HTML or as XML. Values that are not a [HTML MIME type](https://mimesniff.spec.whatwg.org/#html-mime-type) or an [XML MIME type](https://mimesniff.spec.whatwg.org/#xml-mime-type) will throw. It defaults to `"text/html"`. If a `charset` parameter is present, it can affect [binary data processing](#encoding-sniffing).
- `includeNodeLocations` preserves the location info produced by the HTML parser, allowing you to retrieve it with the `nodeLocation()` method (described below). It also ensures that line numbers reported in exception stack traces for code running inside `<script>` elements are correct. It defaults to `false` to give the best performance, and cannot be used with an XML content type since our XML parser does not support location info.
- `storageQuota` is the maximum size in code units for the separate storage areas used by `localStorage` and `sessionStorage`. Attempts to store data larger than this limit will cause a `DOMException` to be thrown. By default, it is set to 5,000,000 code units per origin, as inspired by the HTML specification.
Note that both `url` and `referrer` are canonicalized before they're used, so e.g. if you pass in `"https:example.com"`, jsdom will interpret that as if you had given `"https://example.com/"`. If you pass an unparseable URL, the call will throw. (URLs are parsed and serialized according to the [URL Standard](https://url.spec.whatwg.org/).)
### Executing scripts
jsdom's most powerful ability is that it can execute scripts inside the jsdom. These scripts can modify the content of the page and access all the web platform APIs jsdom implements.
However, this is also highly dangerous when dealing with untrusted content. The jsdom sandbox is not foolproof, and code running inside the DOM's `<script>`s can, if it tries hard enough, get access to the Node.js environment, and thus to your machine. As such, the ability to execute scripts embedded in the HTML is disabled by default:
```js
const dom = new JSDOM(`<body>
<div id="content"></div>
<script>document.getElementById("content").append(document.createElement("hr"));</script>
</body>`);
// The script will not be executed, by default:
console.log(dom.window.document.getElementById("content").children.length); // 0
```
To enable executing scripts inside the page, you can use the `runScripts: "dangerously"` option:
```js
const dom = new JSDOM(`<body>
<div id="content"></div>
<script>document.getElementById("content").append(document.createElement("hr"));</script>
</body>`, { runScripts: "dangerously" });
// The script will be executed and modify the DOM:
console.log(dom.window.document.getElementById("content").children.length); // 1
```
Again we emphasize to only use this when feeding jsdom code you know is safe. If you use it on arbitrary user-supplied code, or code from the Internet, you are effectively running untrusted Node.js code, and your machine could be compromised.
If you want to execute _external_ scripts, included via `<script src="">`, you'll also need to ensure that they load them. To do this, add the option `resources: "usable"` [as described below](#loading-subresources). (You'll likely also want to set the `url` option, for the reasons discussed there.)
Event handler attributes, like `<div onclick="">`, are also governed by this setting; they will not function unless `runScripts` is set to `"dangerously"`. (However, event handler _properties_, like `div.onclick = ...`, will function regardless of `runScripts`.)
If you are simply trying to execute script "from the outside", instead of letting `<script>` elements and event handlers attributes run "from the inside", you can use the `runScripts: "outside-only"` option, which enables fresh copies of all the JavaScript spec-provided globals to be installed on `window`. This includes things like `window.Array`, `window.Promise`, etc. It also, notably, includes `window.eval`, which allows running scripts, but with the jsdom `window` as the global:
```js
const dom = new JSDOM(`<body>
<div id="content"></div>
<script>document.getElementById("content").append(document.createElement("hr"));</script>
</body>`, { runScripts: "outside-only" });
// run a script outside of JSDOM:
dom.window.eval('document.getElementById("content").append(document.createElement("p"));');
console.log(dom.window.document.getElementById("content").children.length); // 1
console.log(dom.window.document.getElementsByTagName("hr").length); // 0
console.log(dom.window.document.getElementsByTagName("p").length); // 1
```
This is turned off by default for performance reasons, but is safe to enable.
Note that in the default configuration, without setting `runScripts`, the values of `window.Array`, `window.eval`, etc. will be the same as those provided by the outer Node.js environment. That is, `window.eval === eval` will hold, so `window.eval` will not run scripts in a useful way.
We strongly advise against trying to "execute scripts" by mashing together the jsdom and Node global environments (e.g. by doing `global.window = dom.window`), and then executing scripts or test code inside the Node global environment. Instead, you should treat jsdom like you would a browser, and run all scripts and tests that need access to a DOM inside the jsdom environment, using `window.eval` or `runScripts: "dangerously"`. This might require, for example, creating a browserify bundle to execute as a `<script>` element—just like you would in a browser.
Finally, for advanced use cases you can use the `dom.getInternalVMContext()` method, documented below.
### Pretending to be a visual browser
jsdom does not have the capability to render visual content, and will act like a headless browser by default. It provides hints to web pages through APIs such as `document.hidden` that their content is not visible.
When the `pretendToBeVisual` option is set to `true`, jsdom will pretend that it is rendering and displaying content. It does this by:
* Changing `document.hidden` to return `false` instead of `true`
* Changing `document.visibilityState` to return `"visible"` instead of `"prerender"`
* Enabling `window.requestAnimationFrame()` and `window.cancelAnimationFrame()` methods, which otherwise do not exist
```js
const window = (new JSDOM(``, { pretendToBeVisual: true })).window;
window.requestAnimationFrame(timestamp => {
console.log(timestamp > 0);
});
```
Note that jsdom still [does not do any layout or rendering](#unimplemented-parts-of-the-web-platform), so this is really just about _pretending_ to be visual, not about implementing the parts of the platform a real, visual web browser would implement.
### Loading subresources
#### Basic options
By default, jsdom will not load any subresources such as scripts, stylesheets, images, or iframes. If you'd like jsdom to load such resources, you can pass the `resources: "usable"` option, which will load all usable resources. Those are:
* Frames and iframes, via `<frame>` and `<iframe>`
* Stylesheets, via `<link rel="stylesheet">`
* Scripts, via `<script>`, but only if `runScripts: "dangerously"` is also set
* Images, via `<img>`, but only if the `canvas` npm package is also installed (see "[Canvas Support](#canvas-support)" below)
When attempting to load resources, recall that the default value for the `url` option is `"about:blank"`, which means that any resources included via relative URLs will fail to load. (The result of trying to parse the URL `/something` against the URL `about:blank` is an error.) So, you'll likely want to set a non-default value for the `url` option in those cases, or use one of the [convenience APIs](#convenience-apis) that do so automatically.
#### Advanced configuration
To more fully customize jsdom's resource-loading behavior, you can pass an instance of the `ResourceLoader` class as the `resources` option value:
```js
const resourceLoader = new jsdom.ResourceLoader({
proxy: "http://127.0.0.1:9001",
strictSSL: false,
userAgent: "Mellblomenator/9000",
});
const dom = new JSDOM(``, { resources: resourceLoader });
```
The three options to the `ResourceLoader` constructor are:
- `proxy` is the address of an HTTP proxy to be used.
- `strictSSL` can be set to false to disable the requirement that SSL certificates be valid.
- `userAgent` affects the `User-Agent` header sent, and thus the resulting value for `navigator.userAgent`. It defaults to <code>\`Mozilla/5.0 (${process.platform || "unknown OS"}) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/${jsdomVersion}\`</code>.
You can further customize resource fetching by subclassing `ResourceLoader` and overriding the `fetch()` method. For example, here is a version that overrides the response provided for a specific URL:
```js
class CustomResourceLoader extends jsdom.ResourceLoader {
fetch(url, options) {
// Override the contents of this script to do something unusual.
if (url === "https://example.com/some-specific-script.js") {
return Promise.resolve(Buffer.from("window.someGlobal = 5;"));
}
return super.fetch(url, options);
}
}
```
jsdom will call your custom resource loader's `fetch()` method whenever it encounters a "usable" resource, per the above section. The method takes a URL string, as well as a few options which you should pass through unmodified if calling `super.fetch()`. It must return a promise for a Node.js `Buffer` object, or return `null` if the resource is intentionally not to be loaded. In general, most cases will want to delegate to `super.fetch()`, as shown.
One of the options you will receive in `fetch()` will be the element (if applicable) that is fetching a resource.
```js
class CustomResourceLoader extends jsdom.ResourceLoader {
fetch(url, options) {
if (options.element) {
console.log(`Element ${options.element.localName} is requesting the url ${url}`);
}
return super.fetch(url, options);
}
}
```
### Virtual consoles
Like web browsers, jsdom has the concept of a "console". This records both information directly sent from the page, via scripts executing inside the document, as well as information from the jsdom implementation itself. We call the user-controllable console a "virtual console", to distinguish it from the Node.js `console` API and from the inside-the-page `window.console` API.
By default, the `JSDOM` constructor will return an instance with a virtual console that forwards all its output to the Node.js console. To create your own virtual console and pass it to jsdom, you can override this default by doing
```js
const virtualConsole = new jsdom.VirtualConsole();
const dom = new JSDOM(``, { virtualConsole });
```
Code like this will create a virtual console with no behavior. You can give it behavior by adding event listeners for all the possible console methods:
```js
virtualConsole.on("error", () => { ... });
virtualConsole.on("warn", () => { ... });
virtualConsole.on("info", () => { ... });
virtualConsole.on("dir", () => { ... });
// ... etc. See https://console.spec.whatwg.org/#logging
```
(Note that it is probably best to set up these event listeners *before* calling `new JSDOM()`, since errors or console-invoking script might occur during parsing.)
If you simply want to redirect the virtual console output to another console, like the default Node.js one, you can do
```js
virtualConsole.sendTo(console);
```
There is also a special event, `"jsdomError"`, which will fire with error objects to report errors from jsdom itself. This is similar to how error messages often show up in web browser consoles, even if they are not initiated by `console.error`. So far, the following errors are output this way:
- Errors loading or parsing subresources (scripts, stylesheets, frames, and iframes)
- Script execution errors that are not handled by a window `onerror` event handler that returns `true` or calls `event.preventDefault()`
- Not-implemented errors resulting from calls to methods, like `window.alert`, which jsdom does not implement, but installs anyway for web compatibility
If you're using `sendTo(c)` to send errors to `c`, by default it will call `c.error(errorStack[, errorDetail])` with information from `"jsdomError"` events. If you'd prefer to maintain a strict one-to-one mapping of events to method calls, and perhaps handle `"jsdomError"`s yourself, then you can do
```js
virtualConsole.sendTo(c, { omitJSDOMErrors: true });
```
### Cookie jars
Like web browsers, jsdom has the concept of a cookie jar, storing HTTP cookies. Cookies that have a URL on the same domain as the document, and are not marked HTTP-only, are accessible via the `document.cookie` API. Additionally, all cookies in the cookie jar will impact the fetching of subresources.
By default, the `JSDOM` constructor will return an instance with an empty cookie jar. To create your own cookie jar and pass it to jsdom, you can override this default by doing
```js
const cookieJar = new jsdom.CookieJar(store, options);
const dom = new JSDOM(``, { cookieJar });
```
This is mostly useful if you want to share the same cookie jar among multiple jsdoms, or prime the cookie jar with certain values ahead of time.
Cookie jars are provided by the [tough-cookie](https://www.npmjs.com/package/tough-cookie) package. The `jsdom.CookieJar` constructor is a subclass of the tough-cookie cookie jar which by default sets the `looseMode: true` option, since that [matches better how browsers behave](https://github.com/whatwg/html/issues/804). If you want to use tough-cookie's utilities and classes yourself, you can use the `jsdom.toughCookie` module export to get access to the tough-cookie module instance packaged with jsdom.
### Intervening before parsing
jsdom allows you to intervene in the creation of a jsdom very early: after the `Window` and `Document` objects are created, but before any HTML is parsed to populate the document with nodes:
```js
const dom = new JSDOM(`<p>Hello</p>`, {
beforeParse(window) {
window.document.childNodes.length === 0;
window.someCoolAPI = () => { /* ... */ };
}
});
```
This is especially useful if you are wanting to modify the environment in some way, for example adding shims for web platform APIs jsdom does not support.
## `JSDOM` object API
Once you have constructed a `JSDOM` object, it will have the following useful capabilities:
### Properties
The property `window` retrieves the `Window` object that was created for you.
The properties `virtualConsole` and `cookieJar` reflect the options you pass in, or the defaults created for you if nothing was passed in for those options.
### Serializing the document with `serialize()`
The `serialize()` method will return the [HTML serialization](https://html.spec.whatwg.org/#html-fragment-serialisation-algorithm) of the document, including the doctype:
```js
const dom = new JSDOM(`<!DOCTYPE html>hello`);
dom.serialize() === "<!DOCTYPE html><html><head></head><body>hello</body></html>";
// Contrast with:
dom.window.document.documentElement.outerHTML === "<html><head></head><body>hello</body></html>";
```
### Getting the source location of a node with `nodeLocation(node)`
The `nodeLocation()` method will find where a DOM node is within the source document, returning the [parse5 location info](https://www.npmjs.com/package/parse5#options-locationinfo) for the node:
```js
const dom = new JSDOM(
`<p>Hello
<img src="foo.jpg">
</p>`,
{ includeNodeLocations: true }
);
const document = dom.window.document;
const bodyEl = document.body; // implicitly created
const pEl = document.querySelector("p");
const textNode = pEl.firstChild;
const imgEl = document.querySelector("img");
console.log(dom.nodeLocation(bodyEl)); // null; it's not in the source
console.log(dom.nodeLocation(pEl)); // { startOffset: 0, endOffset: 39, startTag: ..., endTag: ... }
console.log(dom.nodeLocation(textNode)); // { startOffset: 3, endOffset: 13 }
console.log(dom.nodeLocation(imgEl)); // { startOffset: 13, endOffset: 32 }
```
Note that this feature only works if you have set the `includeNodeLocations` option; node locations are off by default for performance reasons.
### Interfacing with the Node.js `vm` module using `getInternalVMContext()`
The built-in [`vm`](https://nodejs.org/api/vm.html) module of Node.js is what underpins jsdom's script-running magic. Some advanced use cases, like pre-compiling a script and then running it multiple times, benefit from using the `vm` module directly with a jsdom-created `Window`.
To get access to the [contextified global object](https://nodejs.org/api/vm.html#vm_what_does_it_mean_to_contextify_an_object), suitable for use with the `vm` APIs, you can use the `getInternalVMContext()` method:
```js
const { Script } = require("vm");
const dom = new JSDOM(``, { runScripts: "outside-only" });
const script = new Script(`
if (!this.ran) {
this.ran = 0;
}
++this.ran;
`);
const vmContext = dom.getInternalVMContext();
script.runInContext(vmContext);
script.runInContext(vmContext);
script.runInContext(vmContext);
console.assert(dom.window.ran === 3);
```
This is somewhat-advanced functionality, and we advise sticking to normal DOM APIs (such as `window.eval()` or `document.createElement("script")`) unless you have very specific needs.
Note that this method will throw an exception if the `JSDOM` instance was created without `runScripts` set, or if you are [using jsdom in a web browser](#running-jsdom-inside-a-web-browser).
### Reconfiguring the jsdom with `reconfigure(settings)`
The `top` property on `window` is marked `[Unforgeable]` in the spec, meaning it is a non-configurable own property and thus cannot be overridden or shadowed by normal code running inside the jsdom, even using `Object.defineProperty`.
Similarly, at present jsdom does not handle navigation (such as setting `window.location.href = "https://example.com/"`); doing so will cause the virtual console to emit a `"jsdomError"` explaining that this feature is not implemented, and nothing will change: there will be no new `Window` or `Document` object, and the existing `window`'s `location` object will still have all the same property values.
However, if you're acting from outside the window, e.g. in some test framework that creates jsdoms, you can override one or both of these using the special `reconfigure()` method:
```js
const dom = new JSDOM();
dom.window.top === dom.window;
dom.window.location.href === "about:blank";
dom.reconfigure({ windowTop: myFakeTopForTesting, url: "https://example.com/" });
dom.window.top === myFakeTopForTesting;
dom.window.location.href === "https://example.com/";
```
Note that changing the jsdom's URL will impact all APIs that return the current document URL, such as `window.location`, `document.URL`, and `document.documentURI`, as well as the resolution of relative URLs within the document, and the same-origin checks and referrer used while fetching subresources. It will not, however, perform navigation to the contents of that URL; the contents of the DOM will remain unchanged, and no new instances of `Window`, `Document`, etc. will be created.
## Convenience APIs
### `fromURL()`
In addition to the `JSDOM` constructor itself, jsdom provides a promise-returning factory method for constructing a jsdom from a URL:
```js
JSDOM.fromURL("https://example.com/", options).then(dom => {
console.log(dom.serialize());
});
```
The returned promise will fulfill with a `JSDOM` instance if the URL is valid and the request is successful. Any redirects will be followed to their ultimate destination.
The options provided to `fromURL()` are similar to those provided to the `JSDOM` constructor, with the following additional restrictions and consequences:
- The `url` and `contentType` options cannot be provided.
- The `referrer` option is used as the HTTP `Referer` request header of the initial request.
- The `resources` option also affects the initial request; this is useful if you want to, for example, configure a proxy (see above).
- The resulting jsdom's URL, content type, and referrer are determined from the response.
- Any cookies set via HTTP `Set-Cookie` response headers are stored in the jsdom's cookie jar. Similarly, any cookies already in a supplied cookie jar are sent as HTTP `Cookie` request headers.
### `fromFile()`
Similar to `fromURL()`, jsdom also provides a `fromFile()` factory method for constructing a jsdom from a filename:
```js
JSDOM.fromFile("stuff.html", options).then(dom => {
console.log(dom.serialize());
});
```
The returned promise will fulfill with a `JSDOM` instance if the given file can be opened. As usual in Node.js APIs, the filename is given relative to the current working directory.
The options provided to `fromFile()` are similar to those provided to the `JSDOM` constructor, with the following additional defaults:
- The `url` option will default to a file URL corresponding to the given filename, instead of to `"about:blank"`.
- The `contentType` option will default to `"application/xhtml+xml"` if the given filename ends in `.xht`, `.xhtml`, or `.xml`; otherwise it will continue to default to `"text/html"`.
### `fragment()`
For the very simplest of cases, you might not need a whole `JSDOM` instance with all its associated power. You might not even need a `Window` or `Document`! Instead, you just need to parse some HTML, and get a DOM object you can manipulate. For that, we have `fragment()`, which creates a `DocumentFragment` from a given string:
```js
const frag = JSDOM.fragment(`<p>Hello</p><p><strong>Hi!</strong>`);
frag.childNodes.length === 2;
frag.querySelector("strong").textContent === "Hi!";
// etc.
```
Here `frag` is a [`DocumentFragment`](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment) instance, whose contents are created by parsing the provided string. The parsing is done using a `<template>` element, so you can include any element there (including ones with weird parsing rules like `<td>`). It's also important to note that the resulting `DocumentFragment` will not have [an associated browsing context](https://html.spec.whatwg.org/multipage/#concept-document-bc): that is, elements' `ownerDocument` will have a null `defaultView` property, resources will not load, etc.
All invocations of the `fragment()` factory result in `DocumentFragment`s that share the same template owner `Document`. This allows many calls to `fragment()` with no extra overhead. But it also means that calls to `fragment()` cannot be customized with any options.
Note that serialization is not as easy with `DocumentFragment`s as it is with full `JSDOM` objects. If you need to serialize your DOM, you should probably use the `JSDOM` constructor more directly. But for the special case of a fragment containing a single element, it's pretty easy to do through normal means:
```js
const frag = JSDOM.fragment(`<p>Hello</p>`);
console.log(frag.firstChild.outerHTML); // logs "<p>Hello</p>"
```
## Other noteworthy features
### Canvas support
jsdom includes support for using the [`canvas`](https://www.npmjs.com/package/canvas) package to extend any `<canvas>` elements with the canvas API. To make this work, you need to include `canvas` as a dependency in your project, as a peer of `jsdom`. If jsdom can find the `canvas` package, it will use it, but if it's not present, then `<canvas>` elements will behave like `<div>`s. Since jsdom v13, version 2.x of `canvas` is required; version 1.x is no longer supported.
### Encoding sniffing
In addition to supplying a string, the `JSDOM` constructor can also be supplied binary data, in the form of a Node.js [`Buffer`](https://nodejs.org/docs/latest/api/buffer.html) or a standard JavaScript binary data type like `ArrayBuffer`, `Uint8Array`, `DataView`, etc. When this is done, jsdom will [sniff the encoding](https://html.spec.whatwg.org/multipage/syntax.html#encoding-sniffing-algorithm) from the supplied bytes, scanning for `<meta charset>` tags just like a browser does.
If the supplied `contentType` option contains a `charset` parameter, that encoding will override the sniffed encoding—unless a UTF-8 or UTF-16 BOM is present, in which case those take precedence. (Again, this is just like a browser.)
This encoding sniffing also applies to `JSDOM.fromFile()` and `JSDOM.fromURL()`. In the latter case, any `Content-Type` headers sent with the response will take priority, in the same fashion as the constructor's `contentType` option.
Note that in many cases supplying bytes in this fashion can be better than supplying a string. For example, if you attempt to use Node.js's `buffer.toString("utf-8")` API, Node.js will not strip any leading BOMs. If you then give this string to jsdom, it will interpret it verbatim, leaving the BOM intact. But jsdom's binary data decoding code will strip leading BOMs, just like a browser; in such cases, supplying `buffer` directly will give the desired result.
### Closing down a jsdom
Timers in the jsdom (set by `window.setTimeout()` or `window.setInterval()`) will, by definition, execute code in the future in the context of the window. Since there is no way to execute code in the future without keeping the process alive, outstanding jsdom timers will keep your Node.js process alive. Similarly, since there is no way to execute code in the context of an object without keeping that object alive, outstanding jsdom timers will prevent garbage collection of the window on which they are scheduled.
If you want to be sure to shut down a jsdom window, use `window.close()`, which will terminate all running timers (and also remove any event listeners on the window and document).
### Debugging the DOM using Chrome DevTools
In Node.js you can debug programs using Chrome DevTools. See the [official documentation](https://nodejs.org/en/docs/inspector/) for how to get started.
By default jsdom elements are formatted as plain old JS objects in the console. To make it easier to debug, you can use [jsdom-devtools-formatter](https://github.com/jsdom/jsdom-devtools-formatter), which lets you inspect them like real DOM elements.
## Caveats
### Asynchronous script loading
People often have trouble with asynchronous script loading when using jsdom. Many pages load scripts asynchronously, but there is no way to tell when they're done doing so, and thus when it's a good time to run your code and inspect the resulting DOM structure. This is a fundamental limitation; we cannot predict what scripts on the web page will do, and so cannot tell you when they are done loading more scripts.
This can be worked around in a few ways. The best way, if you control the page in question, is to use whatever mechanisms are given by the script loader to detect when loading is done. For example, if you're using a module loader like RequireJS, the code could look like:
```js
// On the Node.js side:
const window = (new JSDOM(...)).window;
window.onModulesLoaded = () => {
console.log("ready to roll!");
};
```
```html
<!-- Inside the HTML you supply to jsdom -->
<script>
requirejs(["entry-module"], () => {
window.onModulesLoaded();
});
</script>
```
If you do not control the page, you could try workarounds such as polling for the presence of a specific element.
For more details, see the discussion in [#640](https://github.com/jsdom/jsdom/issues/640), especially [@matthewkastor](https://github.com/matthewkastor)'s [insightful comment](https://github.com/jsdom/jsdom/issues/640#issuecomment-22216965).
### Unimplemented parts of the web platform
Although we enjoy adding new features to jsdom and keeping it up to date with the latest web specs, it has many missing APIs. Please feel free to file an issue for anything missing, but we're a small and busy team, so a pull request might work even better.
Some features of jsdom are provided by our dependencies. Notable documentation in that regard includes the list of [supported CSS selectors](https://github.com/dperini/nwsapi/wiki/CSS-supported-selectors) for our CSS selector engine, [`nwsapi`](https://github.com/dperini/nwsapi).
Beyond just features that we haven't gotten to yet, there are two major features that are currently outside the scope of jsdom. These are:
- **Navigation**: the ability to change the global object, and all other objects, when clicking a link or assigning `location.href` or similar.
- **Layout**: the ability to calculate where elements will be visually laid out as a result of CSS, which impacts methods like `getBoundingClientRects()` or properties like `offsetTop`.
Currently jsdom has dummy behaviors for some aspects of these features, such as sending a "not implemented" `"jsdomError"` to the virtual console for navigation, or returning zeros for many layout-related properties. Often you can work around these limitations in your code, e.g. by creating new `JSDOM` instances for each page you "navigate" to during a crawl, or using `Object.defineProperty()` to change what various layout-related getters and methods return.
Note that other tools in the same space, such as PhantomJS, do support these features. On the wiki, we have a more complete writeup about [jsdom vs. PhantomJS](https://github.com/jsdom/jsdom/wiki/jsdom-vs.-PhantomJS).
## Supporting jsdom
jsdom is a community-driven project maintained by a team of [volunteers](https://github.com/orgs/jsdom/people). You could support jsdom by:
- [Getting professional support for jsdom](https://tidelift.com/subscription/pkg/npm-jsdom?utm_source=npm-jsdom&utm_medium=referral&utm_campaign=readme) as part of a Tidelift subscription. Tidelift helps making open source sustainable for us while giving teams assurances for maintenance, licensing, and security.
- [Contributing](https://github.com/jsdom/jsdom/blob/main/Contributing.md) directly to the project.
## Getting help
If you need help with jsdom, please feel free to use any of the following venues:
- The [mailing list](https://groups.google.com/group/jsdom) (best for "how do I" questions)
- The [issue tracker](https://github.com/jsdom/jsdom/issues) (best for bug reports)
- The Matrix room: [#jsdom:matrix.org](https://matrix.to/#/#jsdom:matrix.org)

334
node_modules/@jqhtml/ssr/node_modules/jsdom/lib/api.js generated vendored Executable file
View File

@@ -0,0 +1,334 @@
"use strict";
const path = require("path");
const fs = require("fs").promises;
const vm = require("vm");
const toughCookie = require("tough-cookie");
const sniffHTMLEncoding = require("html-encoding-sniffer");
const whatwgURL = require("whatwg-url");
const whatwgEncoding = require("whatwg-encoding");
const { URL } = require("whatwg-url");
const MIMEType = require("whatwg-mimetype");
const idlUtils = require("./jsdom/living/generated/utils.js");
const VirtualConsole = require("./jsdom/virtual-console.js");
const { createWindow } = require("./jsdom/browser/Window.js");
const { parseIntoDocument } = require("./jsdom/browser/parser");
const { fragmentSerialization } = require("./jsdom/living/domparsing/serialization.js");
const ResourceLoader = require("./jsdom/browser/resources/resource-loader.js");
const NoOpResourceLoader = require("./jsdom/browser/resources/no-op-resource-loader.js");
class CookieJar extends toughCookie.CookieJar {
constructor(store, options) {
// jsdom cookie jars must be loose by default
super(store, { looseMode: true, ...options });
}
}
const window = Symbol("window");
let sharedFragmentDocument = null;
class JSDOM {
constructor(input = "", options = {}) {
const mimeType = new MIMEType(options.contentType === undefined ? "text/html" : options.contentType);
const { html, encoding } = normalizeHTML(input, mimeType);
options = transformOptions(options, encoding, mimeType);
this[window] = createWindow(options.windowOptions);
const documentImpl = idlUtils.implForWrapper(this[window]._document);
options.beforeParse(this[window]._globalProxy);
parseIntoDocument(html, documentImpl);
documentImpl.close();
}
get window() {
// It's important to grab the global proxy, instead of just the result of `createWindow(...)`, since otherwise
// things like `window.eval` don't exist.
return this[window]._globalProxy;
}
get virtualConsole() {
return this[window]._virtualConsole;
}
get cookieJar() {
// TODO NEWAPI move _cookieJar to window probably
return idlUtils.implForWrapper(this[window]._document)._cookieJar;
}
serialize() {
return fragmentSerialization(idlUtils.implForWrapper(this[window]._document), { requireWellFormed: false });
}
nodeLocation(node) {
if (!idlUtils.implForWrapper(this[window]._document)._parseOptions.sourceCodeLocationInfo) {
throw new Error("Location information was not saved for this jsdom. Use includeNodeLocations during creation.");
}
return idlUtils.implForWrapper(node).sourceCodeLocation;
}
getInternalVMContext() {
if (!vm.isContext(this[window])) {
throw new TypeError("This jsdom was not configured to allow script running. " +
"Use the runScripts option during creation.");
}
return this[window];
}
reconfigure(settings) {
if ("windowTop" in settings) {
this[window]._top = settings.windowTop;
}
if ("url" in settings) {
const document = idlUtils.implForWrapper(this[window]._document);
const url = whatwgURL.parseURL(settings.url);
if (url === null) {
throw new TypeError(`Could not parse "${settings.url}" as a URL`);
}
document._URL = url;
document._origin = whatwgURL.serializeURLOrigin(document._URL);
this[window]._sessionHistory.currentEntry.url = url;
}
}
static fragment(string = "") {
if (!sharedFragmentDocument) {
sharedFragmentDocument = (new JSDOM()).window.document;
}
const template = sharedFragmentDocument.createElement("template");
template.innerHTML = string;
return template.content;
}
static fromURL(url, options = {}) {
return Promise.resolve().then(() => {
// Remove the hash while sending this through the research loader fetch().
// It gets added back a few lines down when constructing the JSDOM object.
const parsedURL = new URL(url);
const originalHash = parsedURL.hash;
parsedURL.hash = "";
url = parsedURL.href;
options = normalizeFromURLOptions(options);
const resourceLoader = resourcesToResourceLoader(options.resources);
const resourceLoaderForInitialRequest = resourceLoader.constructor === NoOpResourceLoader ?
new ResourceLoader() :
resourceLoader;
const req = resourceLoaderForInitialRequest.fetch(url, {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
cookieJar: options.cookieJar,
referrer: options.referrer
});
return req.then(body => {
const res = req.response;
options = Object.assign(options, {
url: req.href + originalHash,
contentType: res.headers["content-type"],
referrer: req.getHeader("referer") ?? undefined
});
return new JSDOM(body, options);
});
});
}
static async fromFile(filename, options = {}) {
options = normalizeFromFileOptions(filename, options);
const buffer = await fs.readFile(filename);
return new JSDOM(buffer, options);
}
}
function normalizeFromURLOptions(options) {
// Checks on options that are invalid for `fromURL`
if (options.url !== undefined) {
throw new TypeError("Cannot supply a url option when using fromURL");
}
if (options.contentType !== undefined) {
throw new TypeError("Cannot supply a contentType option when using fromURL");
}
// Normalization of options which must be done before the rest of the fromURL code can use them, because they are
// given to request()
const normalized = { ...options };
if (options.referrer !== undefined) {
normalized.referrer = (new URL(options.referrer)).href;
}
if (options.cookieJar === undefined) {
normalized.cookieJar = new CookieJar();
}
return normalized;
// All other options don't need to be processed yet, and can be taken care of in the normal course of things when
// `fromURL` calls `new JSDOM(html, options)`.
}
function normalizeFromFileOptions(filename, options) {
const normalized = { ...options };
if (normalized.contentType === undefined) {
const extname = path.extname(filename);
if (extname === ".xhtml" || extname === ".xht" || extname === ".xml") {
normalized.contentType = "application/xhtml+xml";
}
}
if (normalized.url === undefined) {
normalized.url = new URL("file:" + path.resolve(filename));
}
return normalized;
}
function transformOptions(options, encoding, mimeType) {
const transformed = {
windowOptions: {
// Defaults
url: "about:blank",
referrer: "",
contentType: "text/html",
parsingMode: "html",
parseOptions: {
sourceCodeLocationInfo: false,
scriptingEnabled: false
},
runScripts: undefined,
encoding,
pretendToBeVisual: false,
storageQuota: 5000000,
// Defaults filled in later
resourceLoader: undefined,
virtualConsole: undefined,
cookieJar: undefined
},
// Defaults
beforeParse() { }
};
// options.contentType was parsed into mimeType by the caller.
if (!mimeType.isHTML() && !mimeType.isXML()) {
throw new RangeError(`The given content type of "${options.contentType}" was not a HTML or XML content type`);
}
transformed.windowOptions.contentType = mimeType.essence;
transformed.windowOptions.parsingMode = mimeType.isHTML() ? "html" : "xml";
if (options.url !== undefined) {
transformed.windowOptions.url = (new URL(options.url)).href;
}
if (options.referrer !== undefined) {
transformed.windowOptions.referrer = (new URL(options.referrer)).href;
}
if (options.includeNodeLocations) {
if (transformed.windowOptions.parsingMode === "xml") {
throw new TypeError("Cannot set includeNodeLocations to true with an XML content type");
}
transformed.windowOptions.parseOptions = { sourceCodeLocationInfo: true };
}
transformed.windowOptions.cookieJar = options.cookieJar === undefined ?
new CookieJar() :
options.cookieJar;
transformed.windowOptions.virtualConsole = options.virtualConsole === undefined ?
(new VirtualConsole()).sendTo(console) :
options.virtualConsole;
if (!(transformed.windowOptions.virtualConsole instanceof VirtualConsole)) {
throw new TypeError("virtualConsole must be an instance of VirtualConsole");
}
transformed.windowOptions.resourceLoader = resourcesToResourceLoader(options.resources);
if (options.runScripts !== undefined) {
transformed.windowOptions.runScripts = String(options.runScripts);
if (transformed.windowOptions.runScripts === "dangerously") {
transformed.windowOptions.parseOptions.scriptingEnabled = true;
} else if (transformed.windowOptions.runScripts !== "outside-only") {
throw new RangeError(`runScripts must be undefined, "dangerously", or "outside-only"`);
}
}
if (options.beforeParse !== undefined) {
transformed.beforeParse = options.beforeParse;
}
if (options.pretendToBeVisual !== undefined) {
transformed.windowOptions.pretendToBeVisual = Boolean(options.pretendToBeVisual);
}
if (options.storageQuota !== undefined) {
transformed.windowOptions.storageQuota = Number(options.storageQuota);
}
return transformed;
}
function normalizeHTML(html, mimeType) {
let encoding = "UTF-8";
if (ArrayBuffer.isView(html)) {
html = Buffer.from(html.buffer, html.byteOffset, html.byteLength);
} else if (html instanceof ArrayBuffer) {
html = Buffer.from(html);
}
if (Buffer.isBuffer(html)) {
encoding = sniffHTMLEncoding(html, {
defaultEncoding: mimeType.isXML() ? "UTF-8" : "windows-1252",
transportLayerEncodingLabel: mimeType.parameters.get("charset")
});
html = whatwgEncoding.decode(html, encoding);
} else {
html = String(html);
}
return { html, encoding };
}
function resourcesToResourceLoader(resources) {
switch (resources) {
case undefined: {
return new NoOpResourceLoader();
}
case "usable": {
return new ResourceLoader();
}
default: {
if (!(resources instanceof ResourceLoader)) {
throw new TypeError("resources must be an instance of ResourceLoader");
}
return resources;
}
}
}
exports.JSDOM = JSDOM;
exports.VirtualConsole = VirtualConsole;
exports.CookieJar = CookieJar;
exports.ResourceLoader = ResourceLoader;
exports.toughCookie = toughCookie;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,789 @@
// Ideally, we would use
// https://html.spec.whatwg.org/multipage/rendering.html#the-css-user-agent-style-sheet-and-presentational-hints
// but for now, just use the version from blink. This file is copied from
// https://chromium.googlesource.com/chromium/blink/+/96aa3a280ab7d67178c8f122a04949ce5f8579e0/Source/core/css/html.css
// (removed a line which had octal literals inside since octal literals are not allowed in template strings)
// We use a .js file because otherwise we can't browserify this. (brfs is unusable due to lack of ES2015 support)
module.exports = `
/*
* The default style sheet used to render HTML.
*
* Copyright (C) 2000 Lars Knoll (knoll@kde.org)
* Copyright (C) 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011 Apple Inc. All rights reserved.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public
* License as published by the Free Software Foundation; either
* version 2 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Library General Public License for more details.
*
* You should have received a copy of the GNU Library General Public License
* along with this library; see the file COPYING.LIB. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
* Boston, MA 02110-1301, USA.
*
*/
@namespace "http://www.w3.org/1999/xhtml";
html {
display: block
}
:root {
scroll-blocks-on: start-touch wheel-event
}
/* children of the <head> element all have display:none */
head {
display: none
}
meta {
display: none
}
title {
display: none
}
link {
display: none
}
style {
display: none
}
script {
display: none
}
/* generic block-level elements */
body {
display: block;
margin: 8px
}
p {
display: block;
-webkit-margin-before: 1__qem;
-webkit-margin-after: 1__qem;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
}
div {
display: block
}
layer {
display: block
}
article, aside, footer, header, hgroup, main, nav, section {
display: block
}
marquee {
display: inline-block;
}
address {
display: block
}
blockquote {
display: block;
-webkit-margin-before: 1__qem;
-webkit-margin-after: 1em;
-webkit-margin-start: 40px;
-webkit-margin-end: 40px;
}
figcaption {
display: block
}
figure {
display: block;
-webkit-margin-before: 1em;
-webkit-margin-after: 1em;
-webkit-margin-start: 40px;
-webkit-margin-end: 40px;
}
q {
display: inline
}
/* nwmatcher does not support ::before and ::after, so we can't render q
correctly: https://html.spec.whatwg.org/multipage/rendering.html#phrasing-content-3
TODO: add q::before and q::after selectors
*/
center {
display: block;
/* special centering to be able to emulate the html4/netscape behaviour */
text-align: -webkit-center
}
hr {
display: block;
-webkit-margin-before: 0.5em;
-webkit-margin-after: 0.5em;
-webkit-margin-start: auto;
-webkit-margin-end: auto;
border-style: inset;
border-width: 1px;
box-sizing: border-box
}
map {
display: inline
}
video {
object-fit: contain;
}
/* heading elements */
h1 {
display: block;
font-size: 2em;
-webkit-margin-before: 0.67__qem;
-webkit-margin-after: 0.67em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
font-weight: bold
}
article h1,
aside h1,
nav h1,
section h1 {
font-size: 1.5em;
-webkit-margin-before: 0.83__qem;
-webkit-margin-after: 0.83em;
}
article article h1,
article aside h1,
article nav h1,
article section h1,
aside article h1,
aside aside h1,
aside nav h1,
aside section h1,
nav article h1,
nav aside h1,
nav nav h1,
nav section h1,
section article h1,
section aside h1,
section nav h1,
section section h1 {
font-size: 1.17em;
-webkit-margin-before: 1__qem;
-webkit-margin-after: 1em;
}
/* Remaining selectors are deleted because nwmatcher does not support
:matches() and expanding the selectors manually would be far too verbose.
Also see https://html.spec.whatwg.org/multipage/rendering.html#sections-and-headings
TODO: rewrite to use :matches() when nwmatcher supports it.
*/
h2 {
display: block;
font-size: 1.5em;
-webkit-margin-before: 0.83__qem;
-webkit-margin-after: 0.83em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
font-weight: bold
}
h3 {
display: block;
font-size: 1.17em;
-webkit-margin-before: 1__qem;
-webkit-margin-after: 1em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
font-weight: bold
}
h4 {
display: block;
-webkit-margin-before: 1.33__qem;
-webkit-margin-after: 1.33em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
font-weight: bold
}
h5 {
display: block;
font-size: .83em;
-webkit-margin-before: 1.67__qem;
-webkit-margin-after: 1.67em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
font-weight: bold
}
h6 {
display: block;
font-size: .67em;
-webkit-margin-before: 2.33__qem;
-webkit-margin-after: 2.33em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
font-weight: bold
}
/* tables */
table {
display: table;
border-collapse: separate;
border-spacing: 2px;
border-color: gray
}
thead {
display: table-header-group;
vertical-align: middle;
border-color: inherit
}
tbody {
display: table-row-group;
vertical-align: middle;
border-color: inherit
}
tfoot {
display: table-footer-group;
vertical-align: middle;
border-color: inherit
}
/* for tables without table section elements (can happen with XHTML or dynamically created tables) */
table > tr {
vertical-align: middle;
}
col {
display: table-column
}
colgroup {
display: table-column-group
}
tr {
display: table-row;
vertical-align: inherit;
border-color: inherit
}
td, th {
display: table-cell;
vertical-align: inherit
}
th {
font-weight: bold
}
caption {
display: table-caption;
text-align: -webkit-center
}
/* lists */
ul, menu, dir {
display: block;
list-style-type: disc;
-webkit-margin-before: 1__qem;
-webkit-margin-after: 1em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
-webkit-padding-start: 40px
}
ol {
display: block;
list-style-type: decimal;
-webkit-margin-before: 1__qem;
-webkit-margin-after: 1em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
-webkit-padding-start: 40px
}
li {
display: list-item;
text-align: -webkit-match-parent;
}
ul ul, ol ul {
list-style-type: circle
}
ol ol ul, ol ul ul, ul ol ul, ul ul ul {
list-style-type: square
}
dd {
display: block;
-webkit-margin-start: 40px
}
dl {
display: block;
-webkit-margin-before: 1__qem;
-webkit-margin-after: 1em;
-webkit-margin-start: 0;
-webkit-margin-end: 0;
}
dt {
display: block
}
ol ul, ul ol, ul ul, ol ol {
-webkit-margin-before: 0;
-webkit-margin-after: 0
}
/* form elements */
form {
display: block;
margin-top: 0__qem;
}
label {
cursor: default;
}
legend {
display: block;
-webkit-padding-start: 2px;
-webkit-padding-end: 2px;
border: none
}
fieldset {
display: block;
-webkit-margin-start: 2px;
-webkit-margin-end: 2px;
-webkit-padding-before: 0.35em;
-webkit-padding-start: 0.75em;
-webkit-padding-end: 0.75em;
-webkit-padding-after: 0.625em;
border: 2px groove ThreeDFace;
min-width: -webkit-min-content;
}
button {
-webkit-appearance: button;
}
/* Form controls don't go vertical. */
input, textarea, select, button, meter, progress {
-webkit-writing-mode: horizontal-tb !important;
}
input, textarea, select, button {
margin: 0__qem;
font: -webkit-small-control;
text-rendering: auto; /* FIXME: Remove when tabs work with optimizeLegibility. */
color: initial;
letter-spacing: normal;
word-spacing: normal;
line-height: normal;
text-transform: none;
text-indent: 0;
text-shadow: none;
display: inline-block;
text-align: start;
}
/* TODO: Add " i" to attribute matchers to support case-insensitive matching */
input[type="hidden"] {
display: none
}
input {
-webkit-appearance: textfield;
padding: 1px;
background-color: white;
border: 2px inset;
-webkit-rtl-ordering: logical;
-webkit-user-select: text;
cursor: auto;
}
input[type="search"] {
-webkit-appearance: searchfield;
box-sizing: border-box;
}
select {
border-radius: 5px;
}
textarea {
-webkit-appearance: textarea;
background-color: white;
border: 1px solid;
-webkit-rtl-ordering: logical;
-webkit-user-select: text;
flex-direction: column;
resize: auto;
cursor: auto;
padding: 2px;
white-space: pre-wrap;
word-wrap: break-word;
}
input[type="password"] {
-webkit-text-security: disc !important;
}
input[type="hidden"], input[type="image"], input[type="file"] {
-webkit-appearance: initial;
padding: initial;
background-color: initial;
border: initial;
}
input[type="file"] {
align-items: baseline;
color: inherit;
text-align: start !important;
}
input[type="radio"], input[type="checkbox"] {
margin: 3px 0.5ex;
padding: initial;
background-color: initial;
border: initial;
}
input[type="button"], input[type="submit"], input[type="reset"] {
-webkit-appearance: push-button;
-webkit-user-select: none;
white-space: pre
}
input[type="button"], input[type="submit"], input[type="reset"], button {
align-items: flex-start;
text-align: center;
cursor: default;
color: ButtonText;
padding: 2px 6px 3px 6px;
border: 2px outset ButtonFace;
background-color: ButtonFace;
box-sizing: border-box
}
input[type="range"] {
-webkit-appearance: slider-horizontal;
padding: initial;
border: initial;
margin: 2px;
color: #909090;
}
input[type="button"]:disabled, input[type="submit"]:disabled, input[type="reset"]:disabled,
button:disabled, select:disabled, optgroup:disabled, option:disabled,
select[disabled]>option {
color: GrayText
}
input[type="button"]:active, input[type="submit"]:active, input[type="reset"]:active, button:active {
border-style: inset
}
input[type="button"]:active:disabled, input[type="submit"]:active:disabled, input[type="reset"]:active:disabled, button:active:disabled {
border-style: outset
}
datalist {
display: none
}
area {
display: inline;
cursor: pointer;
}
param {
display: none
}
input[type="checkbox"] {
-webkit-appearance: checkbox;
box-sizing: border-box;
}
input[type="radio"] {
-webkit-appearance: radio;
box-sizing: border-box;
}
input[type="color"] {
-webkit-appearance: square-button;
width: 44px;
height: 23px;
background-color: ButtonFace;
/* Same as native_theme_base. */
border: 1px #a9a9a9 solid;
padding: 1px 2px;
}
input[type="color"][list] {
-webkit-appearance: menulist;
width: 88px;
height: 23px
}
select {
-webkit-appearance: menulist;
box-sizing: border-box;
align-items: center;
border: 1px solid;
white-space: pre;
-webkit-rtl-ordering: logical;
color: black;
background-color: white;
cursor: default;
}
optgroup {
font-weight: bolder;
display: block;
}
option {
font-weight: normal;
display: block;
padding: 0 2px 1px 2px;
white-space: pre;
min-height: 1.2em;
}
output {
display: inline;
}
/* meter */
meter {
-webkit-appearance: meter;
box-sizing: border-box;
display: inline-block;
height: 1em;
width: 5em;
vertical-align: -0.2em;
}
/* progress */
progress {
-webkit-appearance: progress-bar;
box-sizing: border-box;
display: inline-block;
height: 1em;
width: 10em;
vertical-align: -0.2em;
}
/* inline elements */
u, ins {
text-decoration: underline
}
strong, b {
font-weight: bold
}
i, cite, em, var, address, dfn {
font-style: italic
}
tt, code, kbd, samp {
font-family: monospace
}
pre, xmp, plaintext, listing {
display: block;
font-family: monospace;
white-space: pre;
margin: 1__qem 0
}
mark {
background-color: yellow;
color: black
}
big {
font-size: larger
}
small {
font-size: smaller
}
s, strike, del {
text-decoration: line-through
}
sub {
vertical-align: sub;
font-size: smaller
}
sup {
vertical-align: super;
font-size: smaller
}
nobr {
white-space: nowrap
}
/* states */
:focus {
outline: auto 5px -webkit-focus-ring-color
}
/* Read-only text fields do not show a focus ring but do still receive focus */
html:focus, body:focus, input[readonly]:focus {
outline: none
}
embed:focus, iframe:focus, object:focus {
outline: none
}
input:focus, textarea:focus, select:focus {
outline-offset: -2px
}
input[type="button"]:focus,
input[type="checkbox"]:focus,
input[type="file"]:focus,
input[type="hidden"]:focus,
input[type="image"]:focus,
input[type="radio"]:focus,
input[type="reset"]:focus,
input[type="search"]:focus,
input[type="submit"]:focus {
outline-offset: 0
}
/* HTML5 ruby elements */
ruby, rt {
text-indent: 0; /* blocks used for ruby rendering should not trigger this */
}
rt {
line-height: normal;
-webkit-text-emphasis: none;
}
ruby > rt {
display: block;
font-size: 50%;
text-align: start;
}
ruby > rp {
display: none;
}
/* other elements */
noframes {
display: none
}
frameset, frame {
display: block
}
frameset {
border-color: inherit
}
iframe {
border: 2px inset
}
details {
display: block
}
summary {
display: block
}
template {
display: none
}
bdi, output {
unicode-bidi: -webkit-isolate;
}
bdo {
unicode-bidi: bidi-override;
}
textarea[dir=auto] {
unicode-bidi: -webkit-plaintext;
}
dialog:not([open]) {
display: none
}
dialog {
position: absolute;
left: 0;
right: 0;
width: -webkit-fit-content;
height: -webkit-fit-content;
margin: auto;
border: solid;
padding: 1em;
background: white;
color: black
}
[hidden] {
display: none
}
/* noscript is handled internally, as it depends on settings. */
`;

View File

@@ -0,0 +1,312 @@
{
"Object": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Function": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Number": {
"writable": true,
"enumerable": false,
"configurable": true
},
"parseFloat": {
"writable": true,
"enumerable": false,
"configurable": true
},
"parseInt": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Infinity": {
"writable": false,
"enumerable": false,
"configurable": false
},
"NaN": {
"writable": false,
"enumerable": false,
"configurable": false
},
"undefined": {
"writable": false,
"enumerable": false,
"configurable": false
},
"Boolean": {
"writable": true,
"enumerable": false,
"configurable": true
},
"String": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Symbol": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Date": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Promise": {
"writable": true,
"enumerable": false,
"configurable": true
},
"RegExp": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Error": {
"writable": true,
"enumerable": false,
"configurable": true
},
"AggregateError": {
"writable": true,
"enumerable": false,
"configurable": true
},
"EvalError": {
"writable": true,
"enumerable": false,
"configurable": true
},
"RangeError": {
"writable": true,
"enumerable": false,
"configurable": true
},
"ReferenceError": {
"writable": true,
"enumerable": false,
"configurable": true
},
"SyntaxError": {
"writable": true,
"enumerable": false,
"configurable": true
},
"TypeError": {
"writable": true,
"enumerable": false,
"configurable": true
},
"URIError": {
"writable": true,
"enumerable": false,
"configurable": true
},
"globalThis": {
"writable": true,
"enumerable": false,
"configurable": true
},
"JSON": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Math": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Intl": {
"writable": true,
"enumerable": false,
"configurable": true
},
"ArrayBuffer": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Atomics": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Uint8Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Int8Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Uint16Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Int16Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Uint32Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Int32Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Float32Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Float64Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Uint8ClampedArray": {
"writable": true,
"enumerable": false,
"configurable": true
},
"BigUint64Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"BigInt64Array": {
"writable": true,
"enumerable": false,
"configurable": true
},
"DataView": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Map": {
"writable": true,
"enumerable": false,
"configurable": true
},
"BigInt": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Set": {
"writable": true,
"enumerable": false,
"configurable": true
},
"WeakMap": {
"writable": true,
"enumerable": false,
"configurable": true
},
"WeakSet": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Proxy": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Reflect": {
"writable": true,
"enumerable": false,
"configurable": true
},
"FinalizationRegistry": {
"writable": true,
"enumerable": false,
"configurable": true
},
"WeakRef": {
"writable": true,
"enumerable": false,
"configurable": true
},
"decodeURI": {
"writable": true,
"enumerable": false,
"configurable": true
},
"decodeURIComponent": {
"writable": true,
"enumerable": false,
"configurable": true
},
"encodeURI": {
"writable": true,
"enumerable": false,
"configurable": true
},
"encodeURIComponent": {
"writable": true,
"enumerable": false,
"configurable": true
},
"escape": {
"writable": true,
"enumerable": false,
"configurable": true
},
"unescape": {
"writable": true,
"enumerable": false,
"configurable": true
},
"eval": {
"writable": true,
"enumerable": false,
"configurable": true
},
"isFinite": {
"writable": true,
"enumerable": false,
"configurable": true
},
"isNaN": {
"writable": true,
"enumerable": false,
"configurable": true
},
"Iterator": {
"writable": true,
"enumerable": false,
"configurable": true
},
"SharedArrayBuffer": {
"writable": true,
"enumerable": false,
"configurable": true
},
"WebAssembly": {
"writable": true,
"enumerable": false,
"configurable": true
}
}

View File

@@ -0,0 +1,13 @@
"use strict";
module.exports = function (nameForErrorMessage, window) {
if (!window) {
// Do nothing for window-less documents.
return;
}
const error = new Error(`Not implemented: ${nameForErrorMessage}`);
error.type = "not implemented";
window._virtualConsole.emit("jsdomError", error);
};

View File

@@ -0,0 +1,208 @@
"use strict";
const parse5 = require("parse5");
const { createElement } = require("../../living/helpers/create-element");
const { HTML_NS } = require("../../living/helpers/namespaces");
const DocumentType = require("../../living/generated/DocumentType");
const DocumentFragment = require("../../living/generated/DocumentFragment");
const Text = require("../../living/generated/Text");
const Comment = require("../../living/generated/Comment");
const attributes = require("../../living/attributes");
const nodeTypes = require("../../living/node-type");
const serializationAdapter = require("../../living/domparsing/parse5-adapter-serialization");
const {
customElementReactionsStack, invokeCEReactions, lookupCEDefinition
} = require("../../living/helpers/custom-elements");
class JSDOMParse5Adapter {
constructor(documentImpl, options = {}) {
this._documentImpl = documentImpl;
this._globalObject = documentImpl._globalObject;
this._fragment = options.fragment || false;
// Since the createElement hook doesn't provide the parent element, we keep track of this using _currentElement:
// https://github.com/inikulin/parse5/issues/285.
this._currentElement = undefined;
}
_ownerDocument() {
const { _currentElement } = this;
// The _currentElement is undefined when parsing elements at the root of the document.
if (_currentElement) {
return _currentElement.localName === "template" && _currentElement.namespaceURI === HTML_NS ?
_currentElement.content._ownerDocument :
_currentElement._ownerDocument;
}
return this._documentImpl;
}
createDocument() {
// parse5's model assumes that parse(html) will call into here to create the new Document, then return it. However,
// jsdom's model assumes we can create a Window (and through that create an empty Document), do some other setup
// stuff, and then parse, stuffing nodes into that Document as we go. So to adapt between these two models, we just
// return the already-created Document when asked by parse5 to "create" a Document.
return this._documentImpl;
}
createDocumentFragment() {
const ownerDocument = this._ownerDocument();
return DocumentFragment.createImpl(this._globalObject, [], { ownerDocument });
}
// https://html.spec.whatwg.org/#create-an-element-for-the-token
createElement(localName, namespace, attrs) {
const ownerDocument = this._ownerDocument();
const isAttribute = attrs.find(attr => attr.name === "is");
const isValue = isAttribute ? isAttribute.value : null;
const definition = lookupCEDefinition(ownerDocument, namespace, localName);
let willExecuteScript = false;
if (definition !== null && !this._fragment) {
willExecuteScript = true;
}
if (willExecuteScript) {
ownerDocument._throwOnDynamicMarkupInsertionCounter++;
customElementReactionsStack.push([]);
}
const element = createElement(ownerDocument, localName, namespace, null, isValue, willExecuteScript);
this.adoptAttributes(element, attrs);
if (willExecuteScript) {
const queue = customElementReactionsStack.pop();
invokeCEReactions(queue);
ownerDocument._throwOnDynamicMarkupInsertionCounter--;
}
if ("_parserInserted" in element) {
element._parserInserted = true;
}
return element;
}
createCommentNode(data) {
const ownerDocument = this._ownerDocument();
return Comment.createImpl(this._globalObject, [], { data, ownerDocument });
}
appendChild(parentNode, newNode) {
parentNode._append(newNode);
}
insertBefore(parentNode, newNode, referenceNode) {
parentNode._insert(newNode, referenceNode);
}
setTemplateContent(templateElement, contentFragment) {
// This code makes the glue between jsdom and parse5 HTMLTemplateElement parsing:
//
// * jsdom during the construction of the HTMLTemplateElement (for example when create via
// `document.createElement("template")`), creates a DocumentFragment and set it into _templateContents.
// * parse5 when parsing a <template> tag creates an HTMLTemplateElement (`createElement` adapter hook) and also
// create a DocumentFragment (`createDocumentFragment` adapter hook).
//
// At this point we now have to replace the one created in jsdom with one created by parse5.
const { _ownerDocument, _host } = templateElement._templateContents;
contentFragment._ownerDocument = _ownerDocument;
contentFragment._host = _host;
templateElement._templateContents = contentFragment;
}
setDocumentType(document, name, publicId, systemId) {
const ownerDocument = this._ownerDocument();
const documentType = DocumentType.createImpl(this._globalObject, [], { name, publicId, systemId, ownerDocument });
document._append(documentType);
}
setDocumentMode(document, mode) {
// TODO: the rest of jsdom ignores this
document._mode = mode;
}
detachNode(node) {
node.remove();
}
insertText(parentNode, text) {
const { lastChild } = parentNode;
if (lastChild && lastChild.nodeType === nodeTypes.TEXT_NODE) {
lastChild.data += text;
} else {
const ownerDocument = this._ownerDocument();
const textNode = Text.createImpl(this._globalObject, [], { data: text, ownerDocument });
parentNode._append(textNode);
}
}
insertTextBefore(parentNode, text, referenceNode) {
const { previousSibling } = referenceNode;
if (previousSibling && previousSibling.nodeType === nodeTypes.TEXT_NODE) {
previousSibling.data += text;
} else {
const ownerDocument = this._ownerDocument();
const textNode = Text.createImpl(this._globalObject, [], { data: text, ownerDocument });
parentNode._append(textNode, referenceNode);
}
}
adoptAttributes(element, attrs) {
for (const attr of attrs) {
const prefix = attr.prefix === "" ? null : attr.prefix;
attributes.setAttributeValue(element, attr.name, attr.value, prefix, attr.namespace);
}
}
onItemPush(after) {
this._currentElement = after;
after._pushedOnStackOfOpenElements?.();
}
onItemPop(before, newTop) {
this._currentElement = newTop;
before._poppedOffStackOfOpenElements?.();
}
}
// Assign shared adapters with serializer.
Object.assign(JSDOMParse5Adapter.prototype, serializationAdapter);
function parseFragment(markup, contextElement) {
const ownerDocument = contextElement.localName === "template" && contextElement.namespaceURI === HTML_NS ?
contextElement.content._ownerDocument :
contextElement._ownerDocument;
const config = {
...ownerDocument._parseOptions,
sourceCodeLocationInfo: false,
treeAdapter: new JSDOMParse5Adapter(ownerDocument, { fragment: true })
};
return parse5.parseFragment(contextElement, markup, config);
}
function parseIntoDocument(markup, ownerDocument) {
const config = {
...ownerDocument._parseOptions,
treeAdapter: new JSDOMParse5Adapter(ownerDocument)
};
return parse5.parse(markup, config);
}
module.exports = {
parseFragment,
parseIntoDocument
};

View File

@@ -0,0 +1,37 @@
"use strict";
const xmlParser = require("./xml");
const htmlParser = require("./html");
// https://w3c.github.io/DOM-Parsing/#dfn-fragment-parsing-algorithm
function parseFragment(markup, contextElement) {
const { _parsingMode } = contextElement._ownerDocument;
let parseAlgorithm;
if (_parsingMode === "html") {
parseAlgorithm = htmlParser.parseFragment;
} else if (_parsingMode === "xml") {
parseAlgorithm = xmlParser.parseFragment;
}
// Note: HTML and XML fragment parsing algorithm already return a document fragments; no need to do steps 3 and 4
return parseAlgorithm(markup, contextElement);
}
function parseIntoDocument(markup, ownerDocument) {
const { _parsingMode } = ownerDocument;
let parseAlgorithm;
if (_parsingMode === "html") {
parseAlgorithm = htmlParser.parseIntoDocument;
} else if (_parsingMode === "xml") {
parseAlgorithm = xmlParser.parseIntoDocument;
}
return parseAlgorithm(markup, ownerDocument);
}
module.exports = {
parseFragment,
parseIntoDocument
};

View File

@@ -0,0 +1,202 @@
"use strict";
const { SaxesParser } = require("saxes");
const DOMException = require("../../living/generated/DOMException");
const { createElement } = require("../../living/helpers/create-element");
const DocumentFragment = require("../../living/generated/DocumentFragment");
const DocumentType = require("../../living/generated/DocumentType");
const CDATASection = require("../../living/generated/CDATASection");
const Comment = require("../../living/generated/Comment");
const ProcessingInstruction = require("../../living/generated/ProcessingInstruction");
const Text = require("../../living/generated/Text");
const attributes = require("../../living/attributes");
const { HTML_NS } = require("../../living/helpers/namespaces");
const HTML5_DOCTYPE = /<!doctype html>/i;
const PUBLIC_DOCTYPE = /<!doctype\s+([^\s]+)\s+public\s+"([^"]+)"\s+"([^"]+)"/i;
const SYSTEM_DOCTYPE = /<!doctype\s+([^\s]+)\s+system\s+"([^"]+)"/i;
const CUSTOM_NAME_DOCTYPE = /<!doctype\s+([^\s>]+)/i;
function parseDocType(globalObject, ownerDocument, html) {
if (HTML5_DOCTYPE.test(html)) {
return createDocumentType(globalObject, ownerDocument, "html", "", "");
}
const publicPieces = PUBLIC_DOCTYPE.exec(html);
if (publicPieces) {
return createDocumentType(globalObject, ownerDocument, publicPieces[1], publicPieces[2], publicPieces[3]);
}
const systemPieces = SYSTEM_DOCTYPE.exec(html);
if (systemPieces) {
return createDocumentType(globalObject, ownerDocument, systemPieces[1], "", systemPieces[2]);
}
const namePiece = CUSTOM_NAME_DOCTYPE.exec(html)[1] || "html";
return createDocumentType(globalObject, ownerDocument, namePiece, "", "");
}
function createDocumentType(globalObject, ownerDocument, name, publicId, systemId) {
return DocumentType.createImpl(globalObject, [], { ownerDocument, name, publicId, systemId });
}
function isHTMLTemplateElement(element) {
return element.tagName === "template" && element.namespaceURI === HTML_NS;
}
function createParser(rootNode, globalObject, saxesOptions) {
const parser = new SaxesParser({
...saxesOptions,
// Browsers always have namespace support.
xmlns: true,
// We force the parser to treat all documents (even documents declaring themselves to be XML 1.1 documents) as XML
// 1.0 documents. See https://github.com/jsdom/jsdom/issues/2677 for a discussion of the stakes.
defaultXMLVersion: "1.0",
forceXMLVersion: true
});
const openStack = [rootNode];
function getOwnerDocument() {
const currentElement = openStack[openStack.length - 1];
return isHTMLTemplateElement(currentElement) ?
currentElement._templateContents._ownerDocument :
currentElement._ownerDocument;
}
function appendChild(child) {
const parentElement = openStack[openStack.length - 1];
if (isHTMLTemplateElement(parentElement)) {
parentElement._templateContents._insert(child, null);
} else {
parentElement._insert(child, null);
}
}
parser.on("text", saxesOptions.fragment ?
// In a fragment, all text events produced by saxes must result in a text
// node.
data => {
const ownerDocument = getOwnerDocument();
appendChild(Text.createImpl(globalObject, [], { data, ownerDocument }));
} :
// When parsing a whole document, we must ignore those text nodes that are
// produced outside the root element. Saxes produces events for them,
// but DOM trees do not record text outside the root element.
data => {
if (openStack.length > 1) {
const ownerDocument = getOwnerDocument();
appendChild(Text.createImpl(globalObject, [], { data, ownerDocument }));
}
});
parser.on("cdata", data => {
const ownerDocument = getOwnerDocument();
appendChild(CDATASection.createImpl(globalObject, [], { data, ownerDocument }));
});
parser.on("opentag", tag => {
const { local: tagLocal, attributes: tagAttributes } = tag;
const ownerDocument = getOwnerDocument();
const tagNamespace = tag.uri === "" ? null : tag.uri;
const tagPrefix = tag.prefix === "" ? null : tag.prefix;
const isValue = tagAttributes.is === undefined ? null : tagAttributes.is.value;
const elem = createElement(ownerDocument, tagLocal, tagNamespace, tagPrefix, isValue, true);
// We mark a script element as "parser-inserted", which prevents it from
// being immediately executed.
if (tagLocal === "script" && tagNamespace === HTML_NS) {
elem._parserInserted = true;
}
for (const key of Object.keys(tagAttributes)) {
const { prefix, local, uri, value } = tagAttributes[key];
attributes.setAttributeValue(elem, local, value, prefix === "" ? null : prefix, uri === "" ? null : uri);
}
appendChild(elem);
openStack.push(elem);
});
parser.on("closetag", () => {
const elem = openStack.pop();
// Once a script is populated, we can execute it.
if (elem.localName === "script" && elem.namespaceURI === HTML_NS) {
elem._eval();
}
});
parser.on("comment", data => {
const ownerDocument = getOwnerDocument();
appendChild(Comment.createImpl(globalObject, [], { data, ownerDocument }));
});
parser.on("processinginstruction", ({ target, body }) => {
const ownerDocument = getOwnerDocument();
appendChild(ProcessingInstruction.createImpl(globalObject, [], { target, data: body, ownerDocument }));
});
parser.on("doctype", dt => {
const ownerDocument = getOwnerDocument();
appendChild(parseDocType(globalObject, ownerDocument, `<!doctype ${dt}>`));
const entityMatcher = /<!ENTITY ([^ ]+) "([^"]+)">/g;
let result;
while ((result = entityMatcher.exec(dt))) {
const [, name, value] = result;
if (!(name in parser.ENTITIES)) {
parser.ENTITIES[name] = value;
}
}
});
parser.on("error", err => {
throw DOMException.create(globalObject, [err.message, "SyntaxError"]);
});
return parser;
}
function parseFragment(markup, contextElement) {
const { _globalObject, _ownerDocument } = contextElement;
const fragment = DocumentFragment.createImpl(_globalObject, [], { ownerDocument: _ownerDocument });
// Only parseFragment needs resolvePrefix per the saxes documentation:
// https://github.com/lddubeau/saxes#parsing-xml-fragments
const parser = createParser(fragment, _globalObject, {
fragment: true,
resolvePrefix(prefix) {
// saxes wants undefined as the return value if the prefix is not defined, not null.
return contextElement.lookupNamespaceURI(prefix) || undefined;
}
});
parser.write(markup).close();
return fragment;
}
function parseIntoDocument(markup, ownerDocument) {
const { _globalObject } = ownerDocument;
const parser = createParser(ownerDocument, _globalObject, {
fileName: ownerDocument.location && ownerDocument.location.href
});
parser.write(markup).close();
return ownerDocument;
}
module.exports = {
parseFragment,
parseIntoDocument
};

View File

@@ -0,0 +1,114 @@
"use strict";
class QueueItem {
constructor(onLoad, onError, dependentItem) {
this.onLoad = onLoad;
this.onError = onError;
this.data = null;
this.error = null;
this.dependentItem = dependentItem;
}
}
/**
* AsyncResourceQueue is the queue in charge of run the async scripts
* and notify when they finish.
*/
module.exports = class AsyncResourceQueue {
constructor() {
this.items = new Set();
this.dependentItems = new Set();
}
count() {
return this.items.size + this.dependentItems.size;
}
_notify() {
if (this._listener) {
this._listener();
}
}
_check(item) {
let promise;
if (item.onError && item.error) {
promise = item.onError(item.error);
} else if (item.onLoad && item.data) {
promise = item.onLoad(item.data);
}
promise
.then(() => {
this.items.delete(item);
this.dependentItems.delete(item);
if (this.count() === 0) {
this._notify();
}
});
}
setListener(listener) {
this._listener = listener;
}
push(request, onLoad, onError, dependentItem) {
const q = this;
const item = new QueueItem(onLoad, onError, dependentItem);
q.items.add(item);
return request
.then(data => {
item.data = data;
if (dependentItem && !dependentItem.finished) {
q.dependentItems.add(item);
return q.items.delete(item);
}
if (onLoad) {
return q._check(item);
}
q.items.delete(item);
if (q.count() === 0) {
q._notify();
}
return null;
})
.catch(err => {
item.error = err;
if (dependentItem && !dependentItem.finished) {
q.dependentItems.add(item);
return q.items.delete(item);
}
if (onError) {
return q._check(item);
}
q.items.delete(item);
if (q.count() === 0) {
q._notify();
}
return null;
});
}
notifyItem(syncItem) {
for (const item of this.dependentItems) {
if (item.dependentItem === syncItem) {
this._check(item);
}
}
}
};

View File

@@ -0,0 +1,8 @@
"use strict";
const ResourceLoader = require("./resource-loader.js");
module.exports = class NoOpResourceLoader extends ResourceLoader {
fetch() {
return null;
}
};

View File

@@ -0,0 +1,98 @@
"use strict";
const idlUtils = require("../../living/generated/utils");
const { fireAnEvent } = require("../../living/helpers/events");
module.exports = class PerDocumentResourceLoader {
constructor(document) {
this._document = document;
this._defaultEncoding = document._encoding;
this._resourceLoader = document._defaultView ? document._defaultView._resourceLoader : null;
this._requestManager = document._requestManager;
this._queue = document._queue;
this._deferQueue = document._deferQueue;
this._asyncQueue = document._asyncQueue;
}
fetch(url, { element, onLoad, onError }) {
const request = this._resourceLoader.fetch(url, {
cookieJar: this._document._cookieJar,
element: idlUtils.wrapperForImpl(element),
referrer: this._document.URL
});
if (request === null) {
return null;
}
this._requestManager.add(request);
const onErrorWrapped = error => {
this._requestManager.remove(request);
if (onError) {
onError(error);
}
fireAnEvent("error", element);
const err = new Error(`Could not load ${element.localName}: "${url}"`);
err.type = "resource loading";
err.detail = error;
this._document._defaultView._virtualConsole.emit("jsdomError", err);
return Promise.resolve();
};
const onLoadWrapped = data => {
this._requestManager.remove(request);
this._addCookies(url, request.response ? request.response.headers : {});
try {
const result = onLoad ? onLoad(data) : undefined;
return Promise.resolve(result)
.then(() => {
fireAnEvent("load", element);
return Promise.resolve();
})
.catch(err => {
return onErrorWrapped(err);
});
} catch (err) {
return onErrorWrapped(err);
}
};
if (element.localName === "script" && element.hasAttributeNS(null, "async")) {
this._asyncQueue.push(request, onLoadWrapped, onErrorWrapped, this._queue.getLastScript());
} else if (
element.localName === "script" &&
element.hasAttributeNS(null, "defer") &&
this._document.readyState !== "interactive") {
this._deferQueue.push(request, onLoadWrapped, onErrorWrapped, false, element);
} else {
this._queue.push(request, onLoadWrapped, onErrorWrapped, false, element);
}
return request;
}
_addCookies(url, headers) {
let cookies = headers["set-cookie"];
if (!cookies) {
return;
}
if (!Array.isArray(cookies)) {
cookies = [cookies];
}
cookies.forEach(cookie => {
this._document._cookieJar.setCookieSync(cookie, url, { http: true, ignoreError: true });
});
}
};

View File

@@ -0,0 +1,33 @@
"use strict";
/**
* Manage all the request and it is able to abort
* all pending request.
*/
module.exports = class RequestManager {
constructor() {
this.openedRequests = [];
}
add(req) {
this.openedRequests.push(req);
}
remove(req) {
const idx = this.openedRequests.indexOf(req);
if (idx !== -1) {
this.openedRequests.splice(idx, 1);
}
}
close() {
for (const openedRequest of this.openedRequests) {
openedRequest.abort();
}
this.openedRequests = [];
}
size() {
return this.openedRequests.length;
}
};

View File

@@ -0,0 +1,142 @@
"use strict";
const fs = require("fs");
const { fileURLToPath } = require("url");
const { parseURL } = require("whatwg-url");
const dataURLFromRecord = require("data-urls").fromURLRecord;
const packageVersion = require("../../../../package.json").version;
const agentFactory = require("../../living/helpers/agent-factory");
const Request = require("../../living/helpers/http-request");
const IS_BROWSER = Object.prototype.toString.call(process) !== "[object process]";
module.exports = class ResourceLoader {
constructor({
strictSSL = true,
proxy = undefined,
userAgent = `Mozilla/5.0 (${process.platform || "unknown OS"}) AppleWebKit/537.36 ` +
`(KHTML, like Gecko) jsdom/${packageVersion}`
} = {}) {
this._strictSSL = strictSSL;
this._proxy = proxy;
this._userAgent = userAgent;
}
_readDataURL(urlRecord) {
const dataURL = dataURLFromRecord(urlRecord);
let timeoutId;
const promise = new Promise(resolve => {
timeoutId = setTimeout(resolve, 0, Buffer.from(dataURL.body));
});
promise.abort = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
};
return promise;
}
_readFile(filePath) {
let readableStream, abort; // Native Promises doesn't have an "abort" method.
// Creating a promise for two reason:
// 1. fetch always return a promise.
// 2. We need to add an abort handler.
const promise = new Promise((resolve, reject) => {
readableStream = fs.createReadStream(filePath);
let data = Buffer.alloc(0);
abort = reject;
readableStream.on("error", reject);
readableStream.on("data", chunk => {
data = Buffer.concat([data, chunk]);
});
readableStream.on("end", () => {
resolve(data);
});
});
promise.abort = () => {
readableStream.destroy();
const error = new Error("request canceled by user");
error.isAbortError = true;
abort(error);
};
return promise;
}
fetch(urlString, { accept, cookieJar, referrer } = {}) {
const url = parseURL(urlString);
if (!url) {
return Promise.reject(new Error(`Tried to fetch invalid URL ${urlString}`));
}
switch (url.scheme) {
case "data": {
return this._readDataURL(url);
}
case "http":
case "https": {
const agents = agentFactory(this._proxy, this._strictSSL);
const headers = {
"User-Agent": this._userAgent,
"Accept-Language": "en",
"Accept-Encoding": "gzip",
"Accept": accept || "*/*"
};
if (referrer && !IS_BROWSER) {
headers.Referer = referrer;
}
const requestClient = new Request(
urlString,
{ followRedirects: true, cookieJar, agents },
{ headers }
);
const promise = new Promise((resolve, reject) => {
const accumulated = [];
requestClient.once("response", res => {
promise.response = res;
const { statusCode } = res;
// TODO This deviates from the spec when it comes to
// loading resources such as images
if (statusCode < 200 || statusCode > 299) {
requestClient.abort();
reject(new Error(`Resource was not loaded. Status: ${statusCode}`));
}
});
requestClient.on("data", chunk => {
accumulated.push(chunk);
});
requestClient.on("end", () => resolve(Buffer.concat(accumulated)));
requestClient.on("error", reject);
});
// The method fromURL in lib/api.js crashes without the following four
// properties defined on the Promise instance, causing the test suite to halt
requestClient.on("end", () => {
promise.href = requestClient.currentURL;
});
promise.abort = requestClient.abort.bind(requestClient);
promise.getHeader = name => headers[name] || requestClient.getHeader(name);
requestClient.end();
return promise;
}
case "file": {
try {
return this._readFile(fileURLToPath(urlString));
} catch (e) {
return Promise.reject(e);
}
}
default: {
return Promise.reject(new Error(`Tried to fetch URL ${urlString} with invalid scheme ${url.scheme}`));
}
}
}
};

View File

@@ -0,0 +1,142 @@
"use strict";
/**
* Queue for all the resources to be download except async scripts.
* Async scripts have their own queue AsyncResourceQueue.
*/
module.exports = class ResourceQueue {
constructor({ paused, asyncQueue } = {}) {
this.paused = Boolean(paused);
this._asyncQueue = asyncQueue;
}
getLastScript() {
let head = this.tail;
while (head) {
if (head.isScript) {
return head;
}
head = head.prev;
}
return null;
}
_moreScripts() {
let found = false;
let head = this.tail;
while (head && !found) {
found = head.isScript;
head = head.prev;
}
return found;
}
_notify() {
if (this._listener) {
this._listener();
}
}
setListener(listener) {
this._listener = listener;
}
push(request, onLoad, onError, keepLast, element) {
const isScript = element ? element.localName === "script" : false;
if (!request) {
if (isScript && !this._moreScripts()) {
return onLoad();
}
request = Promise.resolve();
}
const q = this;
const item = {
isScript,
err: null,
element,
fired: false,
data: null,
keepLast,
prev: q.tail,
check() {
if (!q.paused && !this.prev && this.fired) {
let promise;
if (this.err && onError) {
promise = onError(this.err);
}
if (!this.err && onLoad) {
promise = onLoad(this.data);
}
Promise.resolve(promise)
.then(() => {
if (this.next) {
this.next.prev = null;
this.next.check();
} else { // q.tail===this
q.tail = null;
q._notify();
}
this.finished = true;
if (q._asyncQueue) {
q._asyncQueue.notifyItem(this);
}
});
}
}
};
if (q.tail) {
if (q.tail.keepLast) {
// if the tail is the load event in document and we receive a new element to load
// we should add this new request before the load event.
if (q.tail.prev) {
q.tail.prev.next = item;
}
item.prev = q.tail.prev;
q.tail.prev = item;
item.next = q.tail;
} else {
q.tail.next = item;
q.tail = item;
}
} else {
q.tail = item;
}
return request
.then(data => {
item.fired = 1;
item.data = data;
item.check();
})
.catch(err => {
item.fired = true;
item.err = err;
item.check();
});
}
resume() {
if (!this.paused) {
return;
}
this.paused = false;
let head = this.tail;
while (head && head.prev) {
head = head.prev;
}
if (head) {
head.check();
}
}
};

View File

@@ -0,0 +1,57 @@
"use strict";
const cssom = require("rrweb-cssom");
const cssstyle = require("cssstyle");
exports.addToCore = core => {
// What works now:
// - Accessing the rules defined in individual stylesheets
// - Modifications to style content attribute are reflected in style property
// - Modifications to style property are reflected in style content attribute
// TODO
// - Modifications to style element's textContent are reflected in sheet property.
// - Modifications to style element's sheet property are reflected in textContent.
// - Modifications to link.href property are reflected in sheet property.
// - Less-used features of link: disabled
// - Less-used features of style: disabled, scoped, title
// - CSSOM-View
// - getComputedStyle(): requires default stylesheet, cascading, inheritance,
// filtering by @media (screen? print?), layout for widths/heights
// - Load events are not in the specs, but apparently some browsers
// implement something. Should onload only fire after all @imports have been
// loaded, or only the primary sheet?
core.StyleSheet = cssom.StyleSheet;
core.MediaList = cssom.MediaList;
core.CSSStyleSheet = cssom.CSSStyleSheet;
core.CSSRule = cssom.CSSRule;
core.CSSStyleRule = cssom.CSSStyleRule;
core.CSSMediaRule = cssom.CSSMediaRule;
core.CSSImportRule = cssom.CSSImportRule;
core.CSSStyleDeclaration = cssstyle.CSSStyleDeclaration;
// Relevant specs
// http://www.w3.org/TR/DOM-Level-2-Style (2000)
// http://www.w3.org/TR/cssom-view/ (2008)
// http://dev.w3.org/csswg/cssom/ (2010) Meant to replace DOM Level 2 Style
// http://www.whatwg.org/specs/web-apps/current-work/multipage/ HTML5, of course
// http://dev.w3.org/csswg/css-style-attr/ not sure what's new here
// Objects that aren't in cssom library but should be:
// CSSRuleList (cssom just uses array)
// CSSFontFaceRule
// CSSPageRule
// These rules don't really make sense to implement, so CSSOM draft makes them
// obsolete.
// CSSCharsetRule
// CSSUnknownRule
// These objects are considered obsolete by CSSOM draft, although modern
// browsers implement them.
// CSSValue
// CSSPrimitiveValue
// CSSValueList
// RGBColor
// Rect
// Counter
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
"use strict";
const AbortSignal = require("../generated/AbortSignal");
class AbortControllerImpl {
constructor(globalObject) {
this.signal = AbortSignal.createImpl(globalObject, []);
}
abort(reason) {
this.signal._signalAbort(reason);
}
}
module.exports = {
implementation: AbortControllerImpl
};

View File

@@ -0,0 +1,84 @@
"use strict";
const { setupForSimpleEventAccessors } = require("../helpers/create-event-accessor");
const { fireAnEvent } = require("../helpers/events");
const EventTargetImpl = require("../events/EventTarget-impl").implementation;
const AbortSignal = require("../generated/AbortSignal");
const DOMException = require("../generated/DOMException");
class AbortSignalImpl extends EventTargetImpl {
constructor(globalObject, args, privateData) {
super(globalObject, args, privateData);
// make event firing possible
this._ownerDocument = globalObject.document;
this.reason = undefined;
this.abortAlgorithms = new Set();
}
get aborted() {
return this.reason !== undefined;
}
throwIfAborted() {
if (this.aborted) {
throw this.reason;
}
}
static abort(globalObject, reason) {
const abortSignal = AbortSignal.createImpl(globalObject, []);
if (reason !== undefined) {
abortSignal.reason = reason;
} else {
abortSignal.reason = DOMException.create(globalObject, ["The operation was aborted.", "AbortError"]);
}
return abortSignal;
}
static timeout(globalObject, milliseconds) {
const signal = AbortSignal.createImpl(globalObject, []);
globalObject.setTimeout(() => {
signal._signalAbort(DOMException.create(globalObject, ["The operation timed out.", "TimeoutError"]));
}, milliseconds);
return signal;
}
_signalAbort(reason) {
if (this.aborted) {
return;
}
if (reason !== undefined) {
this.reason = reason;
} else {
this.reason = DOMException.create(this._globalObject, ["The operation was aborted.", "AbortError"]);
}
for (const algorithm of this.abortAlgorithms) {
algorithm();
}
this.abortAlgorithms.clear();
fireAnEvent("abort", this);
}
_addAlgorithm(algorithm) {
if (this.aborted) {
return;
}
this.abortAlgorithms.add(algorithm);
}
_removeAlgorithm(algorithm) {
this.abortAlgorithms.delete(algorithm);
}
}
setupForSimpleEventAccessors(AbortSignalImpl.prototype, ["abort"]);
module.exports = {
implementation: AbortSignalImpl
};

View File

@@ -0,0 +1,312 @@
"use strict";
const DOMException = require("./generated/DOMException");
const { HTML_NS } = require("./helpers/namespaces");
const { asciiLowercase } = require("./helpers/strings");
const { queueAttributeMutationRecord } = require("./helpers/mutation-observers");
const { enqueueCECallbackReaction } = require("./helpers/custom-elements");
// The following three are for https://dom.spec.whatwg.org/#concept-element-attribute-has. We don't just have a
// predicate tester since removing that kind of flexibility gives us the potential for better future optimizations.
/* eslint-disable no-restricted-properties */
exports.hasAttribute = function (element, A) {
return element._attributeList.includes(A);
};
exports.hasAttributeByName = function (element, name) {
return element._attributesByNameMap.has(name);
};
exports.hasAttributeByNameNS = function (element, namespace, localName) {
return element._attributeList.some(attribute => {
return attribute._localName === localName && attribute._namespace === namespace;
});
};
// https://dom.spec.whatwg.org/#concept-element-attributes-change
exports.changeAttribute = (element, attribute, value) => {
const { _localName, _namespace, _value } = attribute;
queueAttributeMutationRecord(element, _localName, _namespace, _value);
if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
_value,
value,
_namespace
]);
}
attribute._value = value;
// Run jsdom hooks; roughly correspond to spec's "An attribute is set and an attribute is changed."
element._attrModified(attribute._qualifiedName, value, _value);
};
// https://dom.spec.whatwg.org/#concept-element-attributes-append
exports.appendAttribute = function (element, attribute) {
const { _localName, _namespace, _value } = attribute;
queueAttributeMutationRecord(element, _localName, _namespace, null);
if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
null,
_value,
_namespace
]);
}
const attributeList = element._attributeList;
attributeList.push(attribute);
attribute._element = element;
// Sync name cache
const name = attribute._qualifiedName;
const cache = element._attributesByNameMap;
let entry = cache.get(name);
if (!entry) {
entry = [];
cache.set(name, entry);
}
entry.push(attribute);
// Run jsdom hooks; roughly correspond to spec's "An attribute is set and an attribute is added."
element._attrModified(name, _value, null);
};
exports.removeAttribute = function (element, attribute) {
// https://dom.spec.whatwg.org/#concept-element-attributes-remove
const { _localName, _namespace, _value } = attribute;
queueAttributeMutationRecord(element, _localName, _namespace, _value);
if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
_value,
null,
_namespace
]);
}
const attributeList = element._attributeList;
for (let i = 0; i < attributeList.length; ++i) {
if (attributeList[i] === attribute) {
attributeList.splice(i, 1);
attribute._element = null;
// Sync name cache
const name = attribute._qualifiedName;
const cache = element._attributesByNameMap;
const entry = cache.get(name);
entry.splice(entry.indexOf(attribute), 1);
if (entry.length === 0) {
cache.delete(name);
}
// Run jsdom hooks; roughly correspond to spec's "An attribute is removed."
element._attrModified(name, null, attribute._value);
return;
}
}
};
exports.replaceAttribute = function (element, oldAttr, newAttr) {
// https://dom.spec.whatwg.org/#concept-element-attributes-replace
const { _localName, _namespace, _value } = oldAttr;
queueAttributeMutationRecord(element, _localName, _namespace, _value);
if (element._ceState === "custom") {
enqueueCECallbackReaction(element, "attributeChangedCallback", [
_localName,
_value,
newAttr._value,
_namespace
]);
}
const attributeList = element._attributeList;
for (let i = 0; i < attributeList.length; ++i) {
if (attributeList[i] === oldAttr) {
attributeList.splice(i, 1, newAttr);
oldAttr._element = null;
newAttr._element = element;
// Sync name cache
const name = newAttr._qualifiedName;
const cache = element._attributesByNameMap;
let entry = cache.get(name);
if (!entry) {
entry = [];
cache.set(name, entry);
}
entry.splice(entry.indexOf(oldAttr), 1, newAttr);
// Run jsdom hooks; roughly correspond to spec's "An attribute is set and an attribute is changed."
element._attrModified(name, newAttr._value, oldAttr._value);
return;
}
}
};
exports.getAttributeByName = function (element, name) {
// https://dom.spec.whatwg.org/#concept-element-attributes-get-by-name
if (element._namespaceURI === HTML_NS &&
element._ownerDocument._parsingMode === "html") {
name = asciiLowercase(name);
}
const cache = element._attributesByNameMap;
const entry = cache.get(name);
if (!entry) {
return null;
}
return entry[0];
};
exports.getAttributeByNameNS = function (element, namespace, localName) {
// https://dom.spec.whatwg.org/#concept-element-attributes-get-by-namespace
if (namespace === "") {
namespace = null;
}
const attributeList = element._attributeList;
for (let i = 0; i < attributeList.length; ++i) {
const attr = attributeList[i];
if (attr._namespace === namespace && attr._localName === localName) {
return attr;
}
}
return null;
};
// Both of the following functions implement https://dom.spec.whatwg.org/#concept-element-attributes-get-value.
// Separated them into two to keep symmetry with other functions.
exports.getAttributeValue = function (element, localName) {
const attr = exports.getAttributeByNameNS(element, null, localName);
if (!attr) {
return "";
}
return attr._value;
};
exports.getAttributeValueNS = function (element, namespace, localName) {
const attr = exports.getAttributeByNameNS(element, namespace, localName);
if (!attr) {
return "";
}
return attr._value;
};
exports.setAttribute = function (element, attr) {
// https://dom.spec.whatwg.org/#concept-element-attributes-set
if (attr._element !== null && attr._element !== element) {
throw DOMException.create(element._globalObject, ["The attribute is in use.", "InUseAttributeError"]);
}
const oldAttr = exports.getAttributeByNameNS(element, attr._namespace, attr._localName);
if (oldAttr === attr) {
return attr;
}
if (oldAttr !== null) {
exports.replaceAttribute(element, oldAttr, attr);
} else {
exports.appendAttribute(element, attr);
}
return oldAttr;
};
exports.setAttributeValue = function (element, localName, value, prefix, namespace) {
// https://dom.spec.whatwg.org/#concept-element-attributes-set-value
if (prefix === undefined) {
prefix = null;
}
if (namespace === undefined) {
namespace = null;
}
const attribute = exports.getAttributeByNameNS(element, namespace, localName);
if (attribute === null) {
const newAttribute = element._ownerDocument._createAttribute({
namespace,
namespacePrefix: prefix,
localName,
value
});
exports.appendAttribute(element, newAttribute);
return;
}
exports.changeAttribute(element, attribute, value);
};
// https://dom.spec.whatwg.org/#set-an-existing-attribute-value
exports.setAnExistingAttributeValue = (attribute, value) => {
const element = attribute._element;
if (element === null) {
attribute._value = value;
} else {
exports.changeAttribute(element, attribute, value);
}
};
exports.removeAttributeByName = function (element, name) {
// https://dom.spec.whatwg.org/#concept-element-attributes-remove-by-name
const attr = exports.getAttributeByName(element, name);
if (attr !== null) {
exports.removeAttribute(element, attr);
}
return attr;
};
exports.removeAttributeByNameNS = function (element, namespace, localName) {
// https://dom.spec.whatwg.org/#concept-element-attributes-remove-by-namespace
const attr = exports.getAttributeByNameNS(element, namespace, localName);
if (attr !== null) {
exports.removeAttribute(element, attr);
}
return attr;
};
exports.attributeNames = function (element) {
// Needed by https://dom.spec.whatwg.org/#dom-element-getattributenames
return element._attributeList.map(a => a._qualifiedName);
};
exports.hasAttributes = function (element) {
// Needed by https://dom.spec.whatwg.org/#dom-element-hasattributes
return element._attributeList.length > 0;
};

View File

@@ -0,0 +1,60 @@
"use strict";
const { setAnExistingAttributeValue } = require("../attributes.js");
const NodeImpl = require("../nodes/Node-impl.js").implementation;
const { ATTRIBUTE_NODE } = require("../node-type.js");
exports.implementation = class AttrImpl extends NodeImpl {
constructor(globalObject, args, privateData) {
super(globalObject, args, privateData);
this._namespace = privateData.namespace !== undefined ? privateData.namespace : null;
this._namespacePrefix = privateData.namespacePrefix !== undefined ? privateData.namespacePrefix : null;
this._localName = privateData.localName;
this._value = privateData.value !== undefined ? privateData.value : "";
this._element = privateData.element !== undefined ? privateData.element : null;
this.nodeType = ATTRIBUTE_NODE;
this.specified = true;
}
get namespaceURI() {
return this._namespace;
}
get prefix() {
return this._namespacePrefix;
}
get localName() {
return this._localName;
}
get name() {
return this._qualifiedName;
}
get nodeName() {
return this._qualifiedName;
}
get value() {
return this._value;
}
set value(value) {
setAnExistingAttributeValue(this, value);
}
get ownerElement() {
return this._element;
}
get _qualifiedName() {
// https://dom.spec.whatwg.org/#concept-attribute-qualified-name
if (this._namespacePrefix === null) {
return this._localName;
}
return this._namespacePrefix + ":" + this._localName;
}
};

View File

@@ -0,0 +1,78 @@
"use strict";
const DOMException = require("../generated/DOMException");
const idlUtils = require("../generated/utils.js");
const attributes = require("../attributes.js");
const { HTML_NS } = require("../helpers/namespaces");
exports.implementation = class NamedNodeMapImpl {
constructor(globalObject, args, privateData) {
this._element = privateData.element;
this._globalObject = globalObject;
}
get _attributeList() {
return this._element._attributeList;
}
get [idlUtils.supportedPropertyIndices]() {
return this._attributeList.keys();
}
get length() {
return this._attributeList.length;
}
item(index) {
if (index >= this._attributeList.length) {
return null;
}
return this._attributeList[index];
}
get [idlUtils.supportedPropertyNames]() {
const names = new Set(this._attributeList.map(a => a._qualifiedName));
const el = this._element;
if (el._namespaceURI === HTML_NS && el._ownerDocument._parsingMode === "html") {
for (const name of names) {
const lowercaseName = name.toLowerCase();
if (lowercaseName !== name) {
names.delete(name);
}
}
}
return names;
}
getNamedItem(qualifiedName) {
return attributes.getAttributeByName(this._element, qualifiedName);
}
getNamedItemNS(namespace, localName) {
return attributes.getAttributeByNameNS(this._element, namespace, localName);
}
setNamedItem(attr) {
// eslint-disable-next-line no-restricted-properties
return attributes.setAttribute(this._element, attr);
}
setNamedItemNS(attr) {
// eslint-disable-next-line no-restricted-properties
return attributes.setAttribute(this._element, attr);
}
removeNamedItem(qualifiedName) {
const attr = attributes.removeAttributeByName(this._element, qualifiedName);
if (attr === null) {
throw DOMException.create(this._globalObject, [
"Tried to remove an attribute that was not present",
"NotFoundError"
]);
}
return attr;
}
removeNamedItemNS(namespace, localName) {
const attr = attributes.removeAttributeByNameNS(this._element, namespace, localName);
if (attr === null) {
throw DOMException.create(this._globalObject, [
"Tried to remove an attribute that was not present",
"NotFoundError"
]);
}
return attr;
}
};

View File

@@ -0,0 +1,75 @@
"use strict";
const ValidityState = require("../generated/ValidityState");
const { isDisabled } = require("../helpers/form-controls");
const { closest } = require("../helpers/traversal");
const { fireAnEvent } = require("../helpers/events");
exports.implementation = class DefaultConstraintValidationImpl {
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-cva-willvalidate
get willValidate() {
return this._isCandidateForConstraintValidation();
}
get validity() {
if (!this._validity) {
this._validity = ValidityState.createImpl(this._globalObject, [], {
element: this
});
}
return this._validity;
}
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-cva-checkvalidity
checkValidity() {
if (!this._isCandidateForConstraintValidation()) {
return true;
}
if (this._satisfiesConstraints()) {
return true;
}
fireAnEvent("invalid", this, undefined, { cancelable: true });
return false;
}
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-cva-setcustomvalidity
setCustomValidity(message) {
this._customValidityErrorMessage = message;
}
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-cva-reportvalidity
// Since jsdom has no user interaction, it's the same as #checkValidity
reportValidity() {
return this.checkValidity();
}
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-cva-validationmessage
get validationMessage() {
const { validity } = this;
if (!this._isCandidateForConstraintValidation() || this._satisfiesConstraints()) {
return "";
}
const isSufferingFromCustomError = validity.customError;
if (isSufferingFromCustomError) {
return this._customValidityErrorMessage;
}
return "Constraints not satisfied";
}
_isCandidateForConstraintValidation() {
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fe-disabled
return !isDisabled(this) &&
// If an element has a datalist element ancestor,
// it is barred from constraint validation.
closest(this, "datalist") === null &&
!this._barredFromConstraintValidationSpecialization();
}
_isBarredFromConstraintValidation() {
return !this._isCandidateForConstraintValidation();
}
_satisfiesConstraints() {
return this.validity.valid;
}
};

View File

@@ -0,0 +1,66 @@
"use strict";
exports.implementation = class ValidityStateImpl {
constructor(globalObject, args, privateData) {
const { element, state = {} } = privateData;
this._element = element;
this._state = state;
}
get badInput() {
return this._failsConstraint("badInput");
}
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#suffering-from-a-custom-error
get customError() {
return this._element._customValidityErrorMessage !== "";
}
get patternMismatch() {
return this._failsConstraint("patternMismatch");
}
get rangeOverflow() {
return this._failsConstraint("rangeOverflow");
}
get rangeUnderflow() {
return this._failsConstraint("rangeUnderflow");
}
get stepMismatch() {
return this._failsConstraint("stepMismatch");
}
get tooLong() {
return this._failsConstraint("tooLong");
}
get tooShort() {
return this._failsConstraint("tooShort");
}
get typeMismatch() {
return this._failsConstraint("typeMismatch");
}
get valueMissing() {
return this._failsConstraint("valueMissing");
}
_failsConstraint(method) {
const validationMethod = this._state[method];
if (validationMethod) {
return validationMethod();
}
return false;
}
get valid() {
return !(this.badInput || this.valueMissing || this.customError ||
this.patternMismatch || this.rangeOverflow || this.rangeUnderflow ||
this.stepMismatch || this.tooLong || this.tooShort || this.typeMismatch);
}
};

View File

@@ -0,0 +1,68 @@
"use strict";
const nodeCrypto = require("crypto");
const DOMException = require("../generated/DOMException");
// https://w3c.github.io/webcrypto/#crypto-interface
class CryptoImpl {
constructor(globalObject) {
this._globalObject = globalObject;
}
// https://w3c.github.io/webcrypto/#Crypto-method-getRandomValues
getRandomValues(array) {
const typeName = getTypedArrayTypeName(array);
if (!(typeName === "Int8Array" ||
typeName === "Uint8Array" ||
typeName === "Uint8ClampedArray" ||
typeName === "Int16Array" ||
typeName === "Uint16Array" ||
typeName === "Int32Array" ||
typeName === "Uint32Array" ||
typeName === "BigInt64Array" ||
typeName === "BigUint64Array")) {
throw DOMException.create(this._globalObject, [
`getRandomValues() only accepts integer typed arrays`,
"TypeMismatchError"
]);
}
if (array.byteLength > 65536) {
throw DOMException.create(this._globalObject, [
`getRandomValues() cannot generate more than 65536 bytes of random values; ` +
`${array.byteLength} bytes were requested`,
"QuotaExceededError"
]);
}
nodeCrypto.randomFillSync(array);
return array;
}
// https://w3c.github.io/webcrypto/#Crypto-method-randomUUID
randomUUID() {
return nodeCrypto.randomUUID();
}
}
exports.implementation = CryptoImpl;
// See #3395. Subclasses of TypedArrays should properly work, but we can't rely
// on instanceof because Uint8Array may be different across different windows -
// which can happen in JSDOM when running { runScripts: "dangerously" }. As a
// solution, we imitate the behavior of instanceof by walking the proottype
// chain.
function getTypedArrayTypeName(array) {
const target = array.constructor;
const chain = [target.name];
let proto = Object.getPrototypeOf(target);
while (proto) {
chain.push(proto.name);
proto = Object.getPrototypeOf(proto);
}
while (chain.length > 0 && chain[chain.length - 1] !== "TypedArray") {
chain.pop();
}
chain.reverse();
return chain[1];
}

View File

@@ -0,0 +1,38 @@
"use strict";
const idlUtils = require("../generated/utils.js");
exports.implementation = class StyleSheetList {
constructor() {
this._list = [];
}
get length() {
return this._list.length;
}
item(index) {
const result = this._list[index];
return result !== undefined ? result : null;
}
get [idlUtils.supportedPropertyIndices]() {
return this._list.keys();
}
_add(sheet) {
const { _list } = this;
if (!_list.includes(sheet)) {
_list.push(sheet);
}
}
_remove(sheet) {
const { _list } = this;
const index = _list.indexOf(sheet);
if (index >= 0) {
_list.splice(index, 1);
}
}
};

View File

@@ -0,0 +1,268 @@
"use strict";
const webIDLConversions = require("webidl-conversions");
const DOMException = require("../generated/DOMException");
const NODE_TYPE = require("../node-type");
const { HTML_NS } = require("../helpers/namespaces");
const { getHTMLElementInterface } = require("../helpers/create-element");
const { shadowIncludingInclusiveDescendantsIterator } = require("../helpers/shadow-dom");
const { isValidCustomElementName, tryUpgradeElement, enqueueCEUpgradeReaction } = require("../helpers/custom-elements");
const idlUtils = require("../generated/utils");
const IDLFunction = require("../generated/Function.js");
const HTMLUnknownElement = require("../generated/HTMLUnknownElement");
const LIFECYCLE_CALLBACKS = [
"connectedCallback",
"disconnectedCallback",
"adoptedCallback",
"attributeChangedCallback"
];
function convertToSequenceDOMString(obj) {
if (!obj || !obj[Symbol.iterator]) {
throw new TypeError("Invalid Sequence");
}
return Array.from(obj, webIDLConversions.DOMString);
}
// Returns true is the passed value is a valid constructor.
// Borrowed from: https://stackoverflow.com/a/39336206/3832710
function isConstructor(value) {
if (typeof value !== "function") {
return false;
}
try {
const P = new Proxy(value, {
construct() {
return {};
}
});
// eslint-disable-next-line no-new
new P();
return true;
} catch {
return false;
}
}
// https://html.spec.whatwg.org/#customelementregistry
class CustomElementRegistryImpl {
constructor(globalObject) {
this._customElementDefinitions = [];
this._elementDefinitionIsRunning = false;
this._whenDefinedPromiseMap = Object.create(null);
this._globalObject = globalObject;
}
// https://html.spec.whatwg.org/#dom-customelementregistry-define
define(name, constructor, options) {
const { _globalObject } = this;
const ctor = constructor.objectReference;
if (!isConstructor(ctor)) {
throw new TypeError("Constructor argument is not a constructor.");
}
if (!isValidCustomElementName(name)) {
throw DOMException.create(_globalObject, ["Name argument is not a valid custom element name.", "SyntaxError"]);
}
const nameAlreadyRegistered = this._customElementDefinitions.some(entry => entry.name === name);
if (nameAlreadyRegistered) {
throw DOMException.create(_globalObject, [
"This name has already been registered in the registry.",
"NotSupportedError"
]);
}
const ctorAlreadyRegistered = this._customElementDefinitions.some(entry => entry.objectReference === ctor);
if (ctorAlreadyRegistered) {
throw DOMException.create(_globalObject, [
"This constructor has already been registered in the registry.",
"NotSupportedError"
]);
}
let localName = name;
let extendsOption = null;
if (options !== undefined && options.extends) {
extendsOption = options.extends;
}
if (extendsOption !== null) {
if (isValidCustomElementName(extendsOption)) {
throw DOMException.create(_globalObject, [
"Option extends value can't be a valid custom element name.",
"NotSupportedError"
]);
}
const extendsInterface = getHTMLElementInterface(extendsOption);
if (extendsInterface === HTMLUnknownElement) {
throw DOMException.create(_globalObject, [
`${extendsOption} is an HTMLUnknownElement.`,
"NotSupportedError"
]);
}
localName = extendsOption;
}
if (this._elementDefinitionIsRunning) {
throw DOMException.create(_globalObject, [
"Invalid nested custom element definition.",
"NotSupportedError"
]);
}
this._elementDefinitionIsRunning = true;
let disableInternals = false;
let disableShadow = false;
let observedAttributes = [];
const lifecycleCallbacks = {
connectedCallback: null,
disconnectedCallback: null,
adoptedCallback: null,
attributeChangedCallback: null
};
let caughtError;
try {
const { prototype } = ctor;
if (typeof prototype !== "object") {
throw new TypeError("Invalid constructor prototype.");
}
for (const callbackName of LIFECYCLE_CALLBACKS) {
const callbackValue = prototype[callbackName];
if (callbackValue !== undefined) {
lifecycleCallbacks[callbackName] = IDLFunction.convert(_globalObject, callbackValue, {
context: `The lifecycle callback "${callbackName}"`
});
}
}
if (lifecycleCallbacks.attributeChangedCallback !== null) {
const observedAttributesIterable = ctor.observedAttributes;
if (observedAttributesIterable !== undefined) {
observedAttributes = convertToSequenceDOMString(observedAttributesIterable);
}
}
let disabledFeatures = [];
const disabledFeaturesIterable = ctor.disabledFeatures;
if (disabledFeaturesIterable) {
disabledFeatures = convertToSequenceDOMString(disabledFeaturesIterable);
}
disableInternals = disabledFeatures.includes("internals");
disableShadow = disabledFeatures.includes("shadow");
} catch (err) {
caughtError = err;
} finally {
this._elementDefinitionIsRunning = false;
}
if (caughtError !== undefined) {
throw caughtError;
}
const definition = {
name,
localName,
constructor,
objectReference: ctor,
observedAttributes,
lifecycleCallbacks,
disableShadow,
disableInternals,
constructionStack: []
};
this._customElementDefinitions.push(definition);
const document = idlUtils.implForWrapper(this._globalObject._document);
const upgradeCandidates = [];
for (const candidate of shadowIncludingInclusiveDescendantsIterator(document)) {
if (
(candidate._namespaceURI === HTML_NS && candidate._localName === localName) &&
(extendsOption === null || candidate._isValue === name)
) {
upgradeCandidates.push(candidate);
}
}
for (const upgradeCandidate of upgradeCandidates) {
enqueueCEUpgradeReaction(upgradeCandidate, definition);
}
if (this._whenDefinedPromiseMap[name] !== undefined) {
this._whenDefinedPromiseMap[name].resolve(ctor);
delete this._whenDefinedPromiseMap[name];
}
}
// https://html.spec.whatwg.org/#dom-customelementregistry-get
get(name) {
const definition = this._customElementDefinitions.find(entry => entry.name === name);
return definition && definition.objectReference;
}
// https://html.spec.whatwg.org/#dom-customelementregistry-whendefined
whenDefined(name) {
if (!isValidCustomElementName(name)) {
return Promise.reject(DOMException.create(
this._globalObject,
["Name argument is not a valid custom element name.", "SyntaxError"]
));
}
const alreadyRegistered = this._customElementDefinitions.find(entry => entry.name === name);
if (alreadyRegistered) {
return Promise.resolve(alreadyRegistered.objectReference);
}
if (this._whenDefinedPromiseMap[name] === undefined) {
let resolve;
const promise = new Promise(r => {
resolve = r;
});
// Store the pending Promise along with the extracted resolve callback to actually resolve the returned Promise,
// once the custom element is registered.
this._whenDefinedPromiseMap[name] = {
promise,
resolve
};
}
return this._whenDefinedPromiseMap[name].promise;
}
// https://html.spec.whatwg.org/#dom-customelementregistry-upgrade
upgrade(root) {
for (const candidate of shadowIncludingInclusiveDescendantsIterator(root)) {
if (candidate.nodeType === NODE_TYPE.ELEMENT_NODE) {
tryUpgradeElement(candidate);
}
}
}
}
module.exports = {
implementation: CustomElementRegistryImpl
};

Some files were not shown because too many files have changed in this diff Show More