Add progressive breadcrumb resolution with caching
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
291
app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
Normal file
291
app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Rsx_Breadcrumb_Resolver - Progressive breadcrumb resolution with caching
|
||||
*
|
||||
* Provides instant breadcrumb rendering using cached data while validating
|
||||
* against live action data in the background.
|
||||
*
|
||||
* Resolution Phases:
|
||||
* 1. Immediate Cached Render - If ALL chain links cached, render instantly
|
||||
* 2. Active Action Resolution - Await live action's breadcrumb methods
|
||||
* 3. Chain Walk - Walk parents using cache or detached action loads
|
||||
* 4. Render Chain - Emit chain structure (may have null labels)
|
||||
* 5. Progressive Updates - Update labels as they resolve
|
||||
*
|
||||
* Usage:
|
||||
* const cancel = Rsx_Breadcrumb_Resolver.stream(action, url, (data) => {
|
||||
* // 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 {
|
||||
/**
|
||||
* In-memory cache: url -> { label, label_active, parent_url }
|
||||
* Cleared on page reload. Invalidated by visiting pages.
|
||||
*/
|
||||
static _cache = {};
|
||||
|
||||
/**
|
||||
* Generation counter for cancellation detection.
|
||||
* Incremented on each stream() call and when cancelled.
|
||||
*/
|
||||
static _generation = 0;
|
||||
|
||||
/**
|
||||
* Stream breadcrumb data as it becomes available
|
||||
*
|
||||
* @param {Spa_Action} action - The active action instance
|
||||
* @param {string} url - Current URL (pathname + search, no hash)
|
||||
* @param {Function} callback - Called with breadcrumb data on each update
|
||||
* @returns {Function} Cancel function - call to stop streaming
|
||||
*/
|
||||
static stream(action, url, callback) {
|
||||
const generation = ++Rsx_Breadcrumb_Resolver._generation;
|
||||
|
||||
// Normalize URL (strip hash if present)
|
||||
const normalized_url = Rsx_Breadcrumb_Resolver._normalize_url(url);
|
||||
|
||||
// Start async resolution (fires callbacks as data becomes available)
|
||||
Rsx_Breadcrumb_Resolver._resolve(action, normalized_url, generation, callback);
|
||||
|
||||
// Return cancel function
|
||||
return () => {
|
||||
// Only increment if this is still the active generation
|
||||
if (generation === Rsx_Breadcrumb_Resolver._generation) {
|
||||
Rsx_Breadcrumb_Resolver._generation++;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize URL by stripping hash
|
||||
*/
|
||||
static _normalize_url(url) {
|
||||
const hash_index = url.indexOf('#');
|
||||
return hash_index >= 0 ? url.substring(0, hash_index) : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resolution should continue (not cancelled)
|
||||
*/
|
||||
static _is_active(generation) {
|
||||
return generation === Rsx_Breadcrumb_Resolver._generation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main resolution orchestrator
|
||||
*/
|
||||
static async _resolve(action, url, generation, callback) {
|
||||
// Phase 1: Try immediate cached render
|
||||
const cached_chain = Rsx_Breadcrumb_Resolver._try_build_cached_chain(url);
|
||||
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
|
||||
* Returns null if any link is missing from cache
|
||||
*/
|
||||
static _try_build_cached_chain(url) {
|
||||
const chain = [];
|
||||
let current_url = url;
|
||||
|
||||
while (current_url) {
|
||||
const entry = Rsx_Breadcrumb_Resolver._cache[current_url];
|
||||
if (!entry) {
|
||||
// Cache miss - can't build complete chain
|
||||
return null;
|
||||
}
|
||||
|
||||
chain.unshift({
|
||||
url: current_url,
|
||||
label: current_url === url ? entry.label_active : entry.label,
|
||||
is_active: current_url === url,
|
||||
resolved: true
|
||||
});
|
||||
|
||||
current_url = entry.parent_url;
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Resolve all breadcrumb data from the live active action
|
||||
*/
|
||||
static async _resolve_active_action(action, url) {
|
||||
// Await all breadcrumb methods in parallel
|
||||
const [title, label, label_active, parent_url] = await Promise.all([
|
||||
action.page_title(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ class Core_Bundle extends Rsx_Bundle_Abstract
|
||||
'app/RSpade/Core/Models', // Framework models (User_Model, Site_Model, etc.)
|
||||
'app/RSpade/Core/SPA',
|
||||
'app/RSpade/Core/Debug', // Debug components (JS_Tree_Debug_*)
|
||||
'app/RSpade/Breadcrumbs', // Progressive breadcrumb resolution
|
||||
'app/RSpade/Lib',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -787,9 +787,19 @@ class Spa {
|
||||
|
||||
// Check if current container has a component with target class name
|
||||
// jqhtml adds class names to component root elements automatically
|
||||
const $existing = $current_container.children().first();
|
||||
//
|
||||
// Special case for first iteration (i=0):
|
||||
// The top-level layout is rendered ON #spa-root itself, not as a child.
|
||||
// $.component() converts the container element into the component.
|
||||
// So we check if the container itself has the target class.
|
||||
//
|
||||
// For subsequent iterations:
|
||||
// Sublayouts and actions are rendered into the parent's $content area.
|
||||
// The $content element becomes the component (same pattern).
|
||||
// So we still check if the container itself has the target class.
|
||||
const $existing = $current_container;
|
||||
|
||||
if ($existing.length && $existing.hasClass(target_name)) {
|
||||
if ($existing.hasClass(target_name)) {
|
||||
// Match found - can potentially reuse this level
|
||||
const existing_component = $existing.component();
|
||||
|
||||
@@ -848,19 +858,19 @@ class Spa {
|
||||
// Create component
|
||||
const component = $current_container.component(component_name, is_last ? args : {}).component();
|
||||
|
||||
// Wait for render to complete (not full ready - we don't need child data to load)
|
||||
// This allows layout navigation to update immediately while action loads
|
||||
await component.rendered();
|
||||
if (i === 0) {
|
||||
// Top-level layout - set reference immediately
|
||||
Spa.layout = component;
|
||||
}
|
||||
|
||||
if (is_last) {
|
||||
// This is the action
|
||||
// This is the action - set reference but don't wait
|
||||
Spa.action = component;
|
||||
} else {
|
||||
// This is a layout
|
||||
if (i === 0) {
|
||||
// Top-level layout
|
||||
Spa.layout = component;
|
||||
}
|
||||
// Wait for render to complete (not full ready - we don't need child data to load)
|
||||
// This allows layout navigation to update immediately while action loads
|
||||
await component.rendered();
|
||||
|
||||
// Move container to this layout's $content for next iteration
|
||||
$current_container = component.$sid('content');
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
BREADCRUMBS(3) RSX Framework Manual BREADCRUMBS(3)
|
||||
|
||||
NAME
|
||||
breadcrumbs - Layout-managed breadcrumb navigation system for SPA actions
|
||||
breadcrumbs - Progressive breadcrumb navigation with caching
|
||||
|
||||
SYNOPSIS
|
||||
Layout Integration:
|
||||
|
||||
class My_Spa_Layout extends Spa_Layout {
|
||||
on_action(url, action_name, args) {
|
||||
if (this._breadcrumb_cancel) this._breadcrumb_cancel();
|
||||
this._breadcrumb_cancel = Rsx_Breadcrumb_Resolver.stream(
|
||||
this.action, url,
|
||||
(data) => this._render_breadcrumbs(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Action Methods:
|
||||
|
||||
class Contacts_View_Action extends Spa_Action {
|
||||
@@ -15,28 +27,51 @@ SYNOPSIS
|
||||
}
|
||||
}
|
||||
|
||||
Template Component:
|
||||
|
||||
<Breadcrumb_Nav $crumbs="<%= JSON.stringify(chain) %>" />
|
||||
|
||||
DESCRIPTION
|
||||
The RSX breadcrumb system provides hierarchical navigation through action
|
||||
methods that define breadcrumb metadata. Unlike traditional breadcrumb
|
||||
systems where each page hardcodes its full breadcrumb path, RSX uses
|
||||
parent URL traversal to automatically build the breadcrumb chain.
|
||||
The RSX breadcrumb system provides progressive breadcrumb resolution with
|
||||
caching for instant SPA navigation. Breadcrumbs render immediately using
|
||||
cached data while validating against live action data in the background.
|
||||
|
||||
Key characteristics:
|
||||
- Actions define breadcrumb behavior through async methods
|
||||
- Layout walks up the parent chain using Spa.load_detached_action()
|
||||
- Each parent contributes its breadcrumb_label() to the chain
|
||||
- Active page uses breadcrumb_label_active() for descriptive text
|
||||
- Document title set from page_title() method
|
||||
- Instant render from cache when all chain links are cached
|
||||
- Progressive label updates for uncached pages (shows "------" placeholders)
|
||||
- Active page always validates against live action data
|
||||
- In-memory cache persists for session (cleared on page reload)
|
||||
|
||||
This approach provides:
|
||||
- Dynamic breadcrumb content based on loaded data
|
||||
- Automatic chain building without hardcoding paths
|
||||
- Consistent breadcrumb behavior across all SPA actions
|
||||
- Descriptive active breadcrumb text for root pages
|
||||
Resolution Phases:
|
||||
1. Immediate Cached Render - If ALL chain links cached, render instantly
|
||||
2. Active Action Resolution - Await live action's breadcrumb methods
|
||||
3. Chain Walk - Walk parents using cache or detached action loads
|
||||
4. Render Chain - Emit chain structure (may have null labels)
|
||||
5. Progressive Updates - Update labels as they resolve
|
||||
|
||||
CORE API
|
||||
Rsx_Breadcrumb_Resolver.stream(action, url, callback)
|
||||
|
||||
Streams breadcrumb data as it becomes available.
|
||||
|
||||
Parameters:
|
||||
action - The active SPA action instance
|
||||
url - Current URL (pathname + search)
|
||||
callback - Called with breadcrumb data on each update
|
||||
|
||||
Returns:
|
||||
Function - Call to cancel streaming (e.g., on navigation)
|
||||
|
||||
Callback receives:
|
||||
{
|
||||
title: "Page Title" | null,
|
||||
chain: [
|
||||
{ url: "/path", label: "Label" | null,
|
||||
is_active: false, resolved: true },
|
||||
{ url: "/path/sub", label: null,
|
||||
is_active: true, resolved: false }
|
||||
],
|
||||
fully_resolved: true | false
|
||||
}
|
||||
|
||||
When label is null, the Breadcrumb_Nav component displays "------"
|
||||
in muted text as a placeholder.
|
||||
|
||||
ACTION METHODS
|
||||
page_title()
|
||||
@@ -119,48 +154,47 @@ ACTION METHODS
|
||||
}
|
||||
|
||||
LAYOUT INTEGRATION
|
||||
The layout is responsible for building the breadcrumb chain and rendering
|
||||
it. This happens in the layout's on_action() method.
|
||||
The layout uses Rsx_Breadcrumb_Resolver.stream() to progressively render
|
||||
breadcrumbs as data becomes available.
|
||||
|
||||
Building the Chain:
|
||||
async _update_header_from_action() {
|
||||
const chain = [];
|
||||
|
||||
// Start with current action's active label
|
||||
chain.push({
|
||||
label: await this.action.breadcrumb_label_active(),
|
||||
url: null // Active item has no link
|
||||
});
|
||||
|
||||
// Walk up parent chain
|
||||
let parent_url = await this.action.breadcrumb_parent();
|
||||
while (parent_url) {
|
||||
const parent = await Spa.load_detached_action(parent_url, {
|
||||
use_cached_data: true
|
||||
});
|
||||
|
||||
if (!parent) break;
|
||||
|
||||
chain.unshift({
|
||||
label: await parent.breadcrumb_label(),
|
||||
url: parent_url
|
||||
});
|
||||
|
||||
parent_url = await parent.breadcrumb_parent();
|
||||
parent.stop(); // Clean up detached action
|
||||
Complete Example:
|
||||
class My_Spa_Layout extends Spa_Layout {
|
||||
on_create() {
|
||||
this._breadcrumb_cancel = null;
|
||||
}
|
||||
|
||||
this._set_header({
|
||||
title: await this.action.page_title(),
|
||||
breadcrumbs: chain
|
||||
});
|
||||
on_action(url, action_name, args) {
|
||||
// Cancel any previous breadcrumb stream
|
||||
if (this._breadcrumb_cancel) {
|
||||
this._breadcrumb_cancel();
|
||||
}
|
||||
|
||||
Calling from on_action():
|
||||
async on_action(url, action_name, args) {
|
||||
await this._update_header_from_action();
|
||||
// Stream breadcrumbs progressively
|
||||
this._breadcrumb_cancel = Rsx_Breadcrumb_Resolver.stream(
|
||||
this.action,
|
||||
url,
|
||||
(data) => this._render_breadcrumbs(data)
|
||||
);
|
||||
}
|
||||
|
||||
_render_breadcrumbs(data) {
|
||||
// Update title (null = still loading)
|
||||
if (data.title !== null) {
|
||||
this.$sid('header_title').html(data.title);
|
||||
}
|
||||
|
||||
// Update breadcrumbs
|
||||
this.$sid('header_breadcrumbs').component('Breadcrumb_Nav', {
|
||||
crumbs: data.chain
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
The callback is called multiple times:
|
||||
- First with cached data (if full cache hit)
|
||||
- Then with validated active action data
|
||||
- Then progressively as parent labels resolve
|
||||
|
||||
BREADCRUMB_NAV COMPONENT
|
||||
Template:
|
||||
<Define:Breadcrumb_Nav tag="nav" aria-label="breadcrumb">
|
||||
@@ -171,11 +205,21 @@ BREADCRUMB_NAV COMPONENT
|
||||
%>
|
||||
<% if (is_last || !crumb.url) { %>
|
||||
<li class="breadcrumb-item active" aria-current="page">
|
||||
<% if (crumb.label === null) { %>
|
||||
<span class="breadcrumb-placeholder">------</span>
|
||||
<% } else { %>
|
||||
<%= crumb.label %>
|
||||
<% } %>
|
||||
</li>
|
||||
<% } else { %>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="<%= crumb.url %>"><%= crumb.label %></a>
|
||||
<a href="<%= crumb.url %>">
|
||||
<% if (crumb.label === null) { %>
|
||||
<span class="breadcrumb-placeholder">------</span>
|
||||
<% } else { %>
|
||||
<%= crumb.label %>
|
||||
<% } %>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% } %>
|
||||
@@ -270,21 +314,46 @@ DOCUMENT TITLE
|
||||
- RSX - John Smith
|
||||
- RSX - Edit: John Smith
|
||||
|
||||
CACHING
|
||||
The breadcrumb resolver maintains an in-memory cache keyed by URL:
|
||||
|
||||
Cache Structure:
|
||||
{ label, label_active, parent_url }
|
||||
|
||||
Cache Behavior:
|
||||
- Parent links: Use cache if available, skip detached action load
|
||||
- Active link: Show cached value immediately, then validate with
|
||||
fresh data from live action
|
||||
- Fresh values overwrite cached values
|
||||
|
||||
Cache Invalidation:
|
||||
- Visiting a page updates cache for that URL only
|
||||
- Window reload clears entire cache
|
||||
- No TTL - cache persists for session duration
|
||||
- Parent links are never re-validated (only active page validates)
|
||||
|
||||
Example Timeline (first visit to /contacts/123):
|
||||
t=0ms Cache miss for /contacts/123, skip Phase 1
|
||||
t=50ms Active action resolves, update cache
|
||||
t=51ms Walk chain: /contacts is NOT cached, load detached
|
||||
t=100ms /contacts resolves, cache and render chain
|
||||
t=101ms fully_resolved: true
|
||||
|
||||
Example Timeline (second visit to /contacts/123):
|
||||
t=0ms Full cache hit, render immediately
|
||||
t=1ms User sees breadcrumbs instantly
|
||||
t=50ms Active action validates (values unchanged)
|
||||
t=51ms No re-render needed
|
||||
|
||||
PERFORMANCE CONSIDERATIONS
|
||||
Parent Chain Loading:
|
||||
Spa.load_detached_action() with use_cached_data: true uses cached
|
||||
data from previous visits when available, minimizing network requests.
|
||||
Progressive Loading:
|
||||
The resolver doesn't wait for all data before rendering. It emits
|
||||
updates as each piece of data becomes available, showing "------"
|
||||
placeholders for pending labels.
|
||||
|
||||
When visiting Contacts > John Smith > Edit Contact for the first time:
|
||||
1. Edit page loads with fresh data (required)
|
||||
2. View page loads detached with use_cached_data (may hit cache)
|
||||
3. Index page loads detached with use_cached_data (may hit cache)
|
||||
|
||||
Subsequent breadcrumb renders reuse cached data.
|
||||
|
||||
Stopping Detached Actions:
|
||||
Always call parent.stop() after extracting breadcrumb data to clean
|
||||
up event listeners and prevent memory leaks.
|
||||
Detached Action Cleanup:
|
||||
The resolver automatically stops detached actions after extracting
|
||||
breadcrumb data to prevent memory leaks.
|
||||
|
||||
DEFAULT METHODS
|
||||
If an action doesn't define breadcrumb methods, defaults apply:
|
||||
|
||||
@@ -282,6 +282,7 @@ return [
|
||||
'app/RSpade/Core', // Core framework classes (runtime essentials)
|
||||
'app/RSpade/Integrations', // Integration modules (Jqhtml, Scss, etc.)
|
||||
'app/RSpade/Bundles', // Third-party bundles
|
||||
'app/RSpade/Breadcrumbs', // Progressive breadcrumb resolution
|
||||
'app/RSpade/CodeQuality', // Code quality rules and checks
|
||||
'app/RSpade/Lib', // UI features (Flash alerts, etc.)
|
||||
'app/RSpade/temp', // Framework developer testing directory
|
||||
|
||||
Reference in New Issue
Block a user