@useavalon/avalon 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -54
- package/mod.ts +302 -302
- package/package.json +49 -26
- package/src/build/integration-bundler-plugin.ts +116 -116
- package/src/build/integration-config.ts +168 -168
- package/src/build/integration-detection-plugin.ts +117 -117
- package/src/build/integration-resolver-plugin.ts +90 -90
- package/src/build/island-manifest.ts +269 -269
- package/src/build/island-types-generator.ts +476 -476
- package/src/build/mdx-island-transform.ts +464 -464
- package/src/build/mdx-plugin.ts +98 -98
- package/src/build/page-island-transform.ts +598 -598
- package/src/build/prop-extractors/index.ts +21 -21
- package/src/build/prop-extractors/lit.ts +140 -140
- package/src/build/prop-extractors/qwik.ts +16 -16
- package/src/build/prop-extractors/solid.ts +125 -125
- package/src/build/prop-extractors/svelte.ts +194 -194
- package/src/build/prop-extractors/vue.ts +111 -111
- package/src/build/sidecar-file-manager.ts +104 -104
- package/src/build/sidecar-renderer.ts +30 -30
- package/src/client/adapters/index.ts +21 -13
- package/src/client/components.ts +35 -35
- package/src/client/css-hmr-handler.ts +344 -344
- package/src/client/framework-adapter.ts +462 -462
- package/src/client/hmr-coordinator.ts +396 -396
- package/src/client/hmr-error-overlay.js +533 -533
- package/src/client/main.js +824 -816
- package/src/client/types/framework-runtime.d.ts +68 -68
- package/src/client/types/vite-hmr.d.ts +46 -46
- package/src/client/types/vite-virtual-modules.d.ts +70 -60
- package/src/components/Image.tsx +123 -123
- package/src/components/IslandErrorBoundary.tsx +145 -145
- package/src/components/LayoutDataErrorBoundary.tsx +141 -141
- package/src/components/LayoutErrorBoundary.tsx +127 -127
- package/src/components/PersistentIsland.tsx +52 -52
- package/src/components/StreamingErrorBoundary.tsx +233 -233
- package/src/components/StreamingLayout.tsx +538 -538
- package/src/core/components/component-analyzer.ts +192 -192
- package/src/core/components/component-detection.ts +508 -508
- package/src/core/components/enhanced-framework-detector.ts +500 -500
- package/src/core/components/framework-registry.ts +563 -563
- package/src/core/content/mdx-processor.ts +46 -46
- package/src/core/integrations/index.ts +19 -19
- package/src/core/integrations/loader.ts +125 -125
- package/src/core/integrations/registry.ts +175 -175
- package/src/core/islands/island-persistence.ts +325 -325
- package/src/core/islands/island-state-serializer.ts +258 -258
- package/src/core/islands/persistent-island-context.tsx +80 -80
- package/src/core/islands/use-persistent-state.ts +68 -68
- package/src/core/layout/enhanced-layout-resolver.ts +322 -322
- package/src/core/layout/layout-cache-manager.ts +485 -485
- package/src/core/layout/layout-composer.ts +357 -357
- package/src/core/layout/layout-data-loader.ts +516 -516
- package/src/core/layout/layout-discovery.ts +243 -243
- package/src/core/layout/layout-matcher.ts +299 -299
- package/src/core/layout/layout-types.ts +110 -110
- package/src/core/modules/framework-module-resolver.ts +273 -273
- package/src/islands/component-analysis.ts +213 -213
- package/src/islands/css-utils.ts +565 -565
- package/src/islands/discovery/index.ts +80 -80
- package/src/islands/discovery/registry.ts +340 -340
- package/src/islands/discovery/resolver.ts +477 -477
- package/src/islands/discovery/scanner.ts +386 -386
- package/src/islands/discovery/types.ts +117 -117
- package/src/islands/discovery/validator.ts +544 -544
- package/src/islands/discovery/watcher.ts +368 -368
- package/src/islands/framework-detection.ts +428 -428
- package/src/islands/integration-loader.ts +490 -490
- package/src/islands/island.tsx +565 -565
- package/src/islands/render-cache.ts +550 -550
- package/src/islands/types.ts +80 -80
- package/src/islands/universal-css-collector.ts +157 -157
- package/src/islands/universal-head-collector.ts +137 -137
- package/src/layout-system.d.ts +592 -592
- package/src/layout-system.ts +218 -218
- package/src/middleware/discovery.ts +268 -268
- package/src/middleware/executor.ts +315 -315
- package/src/middleware/index.ts +76 -76
- package/src/middleware/types.ts +99 -99
- package/src/nitro/build-config.ts +575 -575
- package/src/nitro/config.ts +483 -483
- package/src/nitro/error-handler.ts +636 -636
- package/src/nitro/index.ts +173 -173
- package/src/nitro/island-manifest.ts +584 -584
- package/src/nitro/middleware-adapter.ts +260 -260
- package/src/nitro/renderer.ts +1471 -1471
- package/src/nitro/route-discovery.ts +439 -439
- package/src/nitro/types.ts +321 -321
- package/src/render/collect-css.ts +198 -198
- package/src/render/error-pages.ts +79 -79
- package/src/render/isolated-ssr-renderer.ts +654 -654
- package/src/render/ssr.ts +1030 -1030
- package/src/schemas/api.ts +30 -30
- package/src/schemas/core.ts +64 -64
- package/src/schemas/index.ts +212 -212
- package/src/schemas/layout.ts +279 -279
- package/src/schemas/routing/index.ts +38 -38
- package/src/schemas/routing.ts +376 -376
- package/src/types/as-island.ts +20 -20
- package/src/types/image.d.ts +106 -106
- package/src/types/index.d.ts +22 -22
- package/src/types/island-jsx.d.ts +33 -33
- package/src/types/island-prop.d.ts +20 -20
- package/src/types/layout.ts +285 -285
- package/src/types/mdx.d.ts +6 -6
- package/src/types/routing.ts +555 -555
- package/src/types/types.ts +5 -5
- package/src/types/urlpattern.d.ts +49 -49
- package/src/types/vite-env.d.ts +11 -11
- package/src/utils/dev-logger.ts +299 -299
- package/src/utils/fs.ts +151 -151
- package/src/vite-plugin/auto-discover.ts +551 -551
- package/src/vite-plugin/config.ts +266 -266
- package/src/vite-plugin/errors.ts +127 -127
- package/src/vite-plugin/image-optimization.ts +156 -156
- package/src/vite-plugin/integration-activator.ts +126 -126
- package/src/vite-plugin/island-sidecar-plugin.ts +176 -176
- package/src/vite-plugin/module-discovery.ts +189 -189
- package/src/vite-plugin/nitro-integration.ts +1354 -1354
- package/src/vite-plugin/plugin.ts +403 -409
- package/src/vite-plugin/types.ts +327 -327
- package/src/vite-plugin/validation.ts +228 -228
- package/src/client/adapters/index.js +0 -12
- package/src/client/adapters/lit-adapter.js +0 -467
- package/src/client/adapters/lit-adapter.ts +0 -654
- package/src/client/adapters/preact-adapter.js +0 -223
- package/src/client/adapters/preact-adapter.ts +0 -331
- package/src/client/adapters/qwik-adapter.js +0 -259
- package/src/client/adapters/qwik-adapter.ts +0 -345
- package/src/client/adapters/react-adapter.js +0 -220
- package/src/client/adapters/react-adapter.ts +0 -353
- package/src/client/adapters/solid-adapter.js +0 -295
- package/src/client/adapters/solid-adapter.ts +0 -451
- package/src/client/adapters/svelte-adapter.js +0 -368
- package/src/client/adapters/svelte-adapter.ts +0 -524
- package/src/client/adapters/vue-adapter.js +0 -278
- package/src/client/adapters/vue-adapter.ts +0 -467
- package/src/client/components.js +0 -23
- package/src/client/css-hmr-handler.js +0 -263
- package/src/client/framework-adapter.js +0 -283
- package/src/client/hmr-coordinator.js +0 -274
|
@@ -1,299 +1,299 @@
|
|
|
1
|
-
import type { LayoutRule, RouteInfo } from './layout-types.ts';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Built-in layout rules for common scenarios
|
|
5
|
-
*/
|
|
6
|
-
export class BuiltInLayoutRules {
|
|
7
|
-
/**
|
|
8
|
-
* Rule to skip HTML layouts for API routes
|
|
9
|
-
* Requirements: 4.1
|
|
10
|
-
*/
|
|
11
|
-
static readonly API_ROUTES_SKIP_LAYOUTS: LayoutRule = {
|
|
12
|
-
matches: (route: RouteInfo): boolean => {
|
|
13
|
-
return route.path.startsWith('/api/');
|
|
14
|
-
},
|
|
15
|
-
apply: false,
|
|
16
|
-
priority: 100,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Rule to apply mobile-specific layouts for mobile user agents
|
|
21
|
-
* Requirements: 4.2
|
|
22
|
-
*/
|
|
23
|
-
static readonly MOBILE_LAYOUT_DETECTION: LayoutRule = {
|
|
24
|
-
matches: (route: RouteInfo, layoutPath?: string): boolean => {
|
|
25
|
-
const userAgent = route.headers.get('user-agent')?.toLowerCase() || '';
|
|
26
|
-
const isMobile = /mobile|android|iphone|ipad|phone|tablet/i.test(userAgent);
|
|
27
|
-
const isMobileLayout = layoutPath?.includes('/mobile/') ?? false;
|
|
28
|
-
// Skip mobile layouts for non-mobile user agents; skip non-mobile layouts for mobile users
|
|
29
|
-
if (isMobileLayout) return !isMobile;
|
|
30
|
-
return isMobile;
|
|
31
|
-
},
|
|
32
|
-
apply: false,
|
|
33
|
-
priority: 50,
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Rule to skip layouts based on specific headers
|
|
38
|
-
* Requirements: 4.3
|
|
39
|
-
*/
|
|
40
|
-
static readonly HEADER_BASED_SKIP: LayoutRule = {
|
|
41
|
-
matches: (route: RouteInfo): boolean => {
|
|
42
|
-
const skipLayout = route.headers.get('x-skip-layout');
|
|
43
|
-
return skipLayout === 'true' || skipLayout === '1';
|
|
44
|
-
},
|
|
45
|
-
apply: false,
|
|
46
|
-
priority: 90,
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Rule to apply admin layouts only for admin routes
|
|
51
|
-
* Requirements: 4.3
|
|
52
|
-
*/
|
|
53
|
-
static readonly ADMIN_LAYOUT_RESTRICTION: LayoutRule = {
|
|
54
|
-
matches: (route: RouteInfo, layoutPath?: string): boolean => {
|
|
55
|
-
// Only restrict admin layouts — if no layoutPath or not an admin layout, don't match
|
|
56
|
-
if (!layoutPath?.includes('/admin/')) return false;
|
|
57
|
-
// Admin layout should only apply to admin routes
|
|
58
|
-
return !route.path.startsWith('/admin/');
|
|
59
|
-
},
|
|
60
|
-
apply: false,
|
|
61
|
-
priority: 60,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
static getAllRules(): LayoutRule[] {
|
|
65
|
-
return [
|
|
66
|
-
this.API_ROUTES_SKIP_LAYOUTS,
|
|
67
|
-
this.MOBILE_LAYOUT_DETECTION,
|
|
68
|
-
this.HEADER_BASED_SKIP,
|
|
69
|
-
this.ADMIN_LAYOUT_RESTRICTION,
|
|
70
|
-
];
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Layout matcher class that handles conditional layout rendering based on rules
|
|
76
|
-
* Requirements: 4.1, 4.2, 4.3, 4.4, 4.5
|
|
77
|
-
*/
|
|
78
|
-
export class LayoutMatcher {
|
|
79
|
-
private rules: LayoutRule[] = [];
|
|
80
|
-
private readonly developmentMode: boolean;
|
|
81
|
-
|
|
82
|
-
constructor(options: { developmentMode?: boolean } = {}) {
|
|
83
|
-
this.developmentMode = options.developmentMode || false;
|
|
84
|
-
this.addBuiltInRules();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Add a new layout rule
|
|
89
|
-
* Requirements: 4.3
|
|
90
|
-
*/
|
|
91
|
-
addRule(rule: LayoutRule): void {
|
|
92
|
-
if (!rule.matches || typeof rule.matches !== 'function') {
|
|
93
|
-
throw new Error('Layout rule must have a valid matches function');
|
|
94
|
-
}
|
|
95
|
-
if (typeof rule.apply !== 'boolean') {
|
|
96
|
-
throw new TypeError('Layout rule must have a boolean apply property');
|
|
97
|
-
}
|
|
98
|
-
if (typeof rule.priority !== 'number') {
|
|
99
|
-
throw new TypeError('Layout rule must have a numeric priority');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
this.rules.push(rule);
|
|
103
|
-
this.sortRulesByPriority();
|
|
104
|
-
|
|
105
|
-
if (this.developmentMode) {
|
|
106
|
-
console.log(`[LayoutMatcher] Added rule with priority ${rule.priority}`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Remove a layout rule
|
|
112
|
-
* Requirements: 4.3
|
|
113
|
-
*/
|
|
114
|
-
removeRule(rule: LayoutRule): void {
|
|
115
|
-
const index = this.rules.indexOf(rule);
|
|
116
|
-
if (index > -1) {
|
|
117
|
-
this.rules.splice(index, 1);
|
|
118
|
-
if (this.developmentMode) {
|
|
119
|
-
console.log(`[LayoutMatcher] Removed rule with priority ${rule.priority}`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Check if a layout should be applied based on all rules
|
|
126
|
-
* Requirements: 4.1, 4.2, 4.3, 4.4, 4.5
|
|
127
|
-
*/
|
|
128
|
-
shouldApplyLayout(layoutPath: string, route: RouteInfo): boolean {
|
|
129
|
-
try {
|
|
130
|
-
const matchingRules = this.getMatchingRules(route, layoutPath);
|
|
131
|
-
|
|
132
|
-
if (matchingRules.length === 0) {
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const result = this.resolveRuleConflicts(matchingRules);
|
|
137
|
-
|
|
138
|
-
if (this.developmentMode) {
|
|
139
|
-
console.log(
|
|
140
|
-
`[LayoutMatcher] Layout ${layoutPath} for route ${route.path}: ${result ? 'APPLY' : 'SKIP'} ` +
|
|
141
|
-
`(${matchingRules.length} rules matched)`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return result;
|
|
146
|
-
} catch (error) {
|
|
147
|
-
if (this.developmentMode) {
|
|
148
|
-
console.warn(
|
|
149
|
-
`[LayoutMatcher] Error evaluating rules for layout ${layoutPath}: ${
|
|
150
|
-
error instanceof Error ? error.message : String(error)
|
|
151
|
-
}`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
return true;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
getRules(): LayoutRule[] {
|
|
159
|
-
return [...this.rules];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
clearRules(): void {
|
|
163
|
-
this.rules = [];
|
|
164
|
-
if (this.developmentMode) {
|
|
165
|
-
console.log('[LayoutMatcher] Cleared all rules');
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private addBuiltInRules(): void {
|
|
170
|
-
for (const rule of BuiltInLayoutRules.getAllRules()) {
|
|
171
|
-
this.rules.push(rule);
|
|
172
|
-
}
|
|
173
|
-
this.sortRulesByPriority();
|
|
174
|
-
if (this.developmentMode) {
|
|
175
|
-
console.log(`[LayoutMatcher] Added ${this.rules.length} built-in rules`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private sortRulesByPriority(): void {
|
|
180
|
-
this.rules.sort((a, b) => b.priority - a.priority);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
private getMatchingRules(route: RouteInfo, layoutPath?: string): LayoutRule[] {
|
|
184
|
-
const matchingRules: LayoutRule[] = [];
|
|
185
|
-
for (const rule of this.rules) {
|
|
186
|
-
try {
|
|
187
|
-
if (rule.matches(route, layoutPath)) {
|
|
188
|
-
matchingRules.push(rule);
|
|
189
|
-
}
|
|
190
|
-
} catch (error) {
|
|
191
|
-
if (this.developmentMode) {
|
|
192
|
-
console.warn(
|
|
193
|
-
`[LayoutMatcher] Error in rule evaluation: ${error instanceof Error ? error.message : String(error)}`,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return matchingRules;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
private resolveRuleConflicts(matchingRules: LayoutRule[]): boolean {
|
|
202
|
-
if (matchingRules.length === 0) return true;
|
|
203
|
-
if (matchingRules.length === 1) return matchingRules[0].apply;
|
|
204
|
-
|
|
205
|
-
const rulesByPriority = new Map<number, LayoutRule[]>();
|
|
206
|
-
for (const rule of matchingRules) {
|
|
207
|
-
if (!rulesByPriority.has(rule.priority)) {
|
|
208
|
-
rulesByPriority.set(rule.priority, []);
|
|
209
|
-
}
|
|
210
|
-
rulesByPriority.get(rule.priority)!.push(rule);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const priorities = Array.from(rulesByPriority.keys()).sort((a, b) => b - a);
|
|
214
|
-
const highestPriorityRules = rulesByPriority.get(priorities[0])!;
|
|
215
|
-
|
|
216
|
-
if (highestPriorityRules.length === 1) {
|
|
217
|
-
return highestPriorityRules[0].apply;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return this.resolveEqualPriorityConflicts(highestPriorityRules);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private resolveEqualPriorityConflicts(rules: LayoutRule[]): boolean {
|
|
224
|
-
const applyCount = rules.filter(rule => rule.apply).length;
|
|
225
|
-
const skipCount = rules.filter(rule => !rule.apply).length;
|
|
226
|
-
|
|
227
|
-
if (skipCount > applyCount) {
|
|
228
|
-
if (this.developmentMode) console.log(`[LayoutMatcher] Conflict resolution: SKIP`);
|
|
229
|
-
return false;
|
|
230
|
-
} else if (applyCount > skipCount) {
|
|
231
|
-
if (this.developmentMode) console.log(`[LayoutMatcher] Conflict resolution: APPLY`);
|
|
232
|
-
return true;
|
|
233
|
-
} else {
|
|
234
|
-
if (this.developmentMode) console.log(`[LayoutMatcher] Conflict resolution: SKIP (tie-breaker)`);
|
|
235
|
-
return false;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Create a custom rule
|
|
241
|
-
* Requirements: 4.3
|
|
242
|
-
*/
|
|
243
|
-
static createCustomRule(matcher: (route: RouteInfo) => boolean, apply: boolean, priority: number = 10): LayoutRule {
|
|
244
|
-
return { matches: matcher, apply, priority };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
static createPathRule(pathPattern: string | RegExp, apply: boolean, priority: number = 10): LayoutRule {
|
|
248
|
-
const matches =
|
|
249
|
-
typeof pathPattern === 'string'
|
|
250
|
-
? (route: RouteInfo) => route.path.includes(pathPattern)
|
|
251
|
-
: (route: RouteInfo) => pathPattern.test(route.path);
|
|
252
|
-
return { matches, apply, priority };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
static createHeaderRule(
|
|
256
|
-
headerName: string,
|
|
257
|
-
headerValue: string | RegExp,
|
|
258
|
-
apply: boolean,
|
|
259
|
-
priority: number = 10,
|
|
260
|
-
): LayoutRule {
|
|
261
|
-
return {
|
|
262
|
-
matches: (route: RouteInfo) => {
|
|
263
|
-
const val = route.headers.get(headerName.toLowerCase());
|
|
264
|
-
if (!val) return false;
|
|
265
|
-
return typeof headerValue === 'string' ? val === headerValue : headerValue.test(val);
|
|
266
|
-
},
|
|
267
|
-
apply,
|
|
268
|
-
priority,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
static createMethodRule(methods: string | string[], apply: boolean, priority: number = 10): LayoutRule {
|
|
273
|
-
const normalizedMethods = new Set((Array.isArray(methods) ? methods : [methods]).map(m => m.toUpperCase()));
|
|
274
|
-
return {
|
|
275
|
-
matches: (route: RouteInfo) => normalizedMethods.has(route.method.toUpperCase()),
|
|
276
|
-
apply,
|
|
277
|
-
priority,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
getDebugInfo(
|
|
282
|
-
layoutPath: string,
|
|
283
|
-
route: RouteInfo,
|
|
284
|
-
): {
|
|
285
|
-
totalRules: number;
|
|
286
|
-
matchingRules: Array<{ priority: number; apply: boolean }>;
|
|
287
|
-
finalDecision: boolean;
|
|
288
|
-
conflictResolution?: string;
|
|
289
|
-
} {
|
|
290
|
-
const matchingRules = this.getMatchingRules(route, layoutPath);
|
|
291
|
-
const finalDecision = this.shouldApplyLayout(layoutPath, route);
|
|
292
|
-
return {
|
|
293
|
-
totalRules: this.rules.length,
|
|
294
|
-
matchingRules: matchingRules.map(rule => ({ priority: rule.priority, apply: rule.apply })),
|
|
295
|
-
finalDecision,
|
|
296
|
-
conflictResolution: matchingRules.length > 1 ? 'priority-based' : 'single-rule',
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
}
|
|
1
|
+
import type { LayoutRule, RouteInfo } from './layout-types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in layout rules for common scenarios
|
|
5
|
+
*/
|
|
6
|
+
export class BuiltInLayoutRules {
|
|
7
|
+
/**
|
|
8
|
+
* Rule to skip HTML layouts for API routes
|
|
9
|
+
* Requirements: 4.1
|
|
10
|
+
*/
|
|
11
|
+
static readonly API_ROUTES_SKIP_LAYOUTS: LayoutRule = {
|
|
12
|
+
matches: (route: RouteInfo): boolean => {
|
|
13
|
+
return route.path.startsWith('/api/');
|
|
14
|
+
},
|
|
15
|
+
apply: false,
|
|
16
|
+
priority: 100,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rule to apply mobile-specific layouts for mobile user agents
|
|
21
|
+
* Requirements: 4.2
|
|
22
|
+
*/
|
|
23
|
+
static readonly MOBILE_LAYOUT_DETECTION: LayoutRule = {
|
|
24
|
+
matches: (route: RouteInfo, layoutPath?: string): boolean => {
|
|
25
|
+
const userAgent = route.headers.get('user-agent')?.toLowerCase() || '';
|
|
26
|
+
const isMobile = /mobile|android|iphone|ipad|phone|tablet/i.test(userAgent);
|
|
27
|
+
const isMobileLayout = layoutPath?.includes('/mobile/') ?? false;
|
|
28
|
+
// Skip mobile layouts for non-mobile user agents; skip non-mobile layouts for mobile users
|
|
29
|
+
if (isMobileLayout) return !isMobile;
|
|
30
|
+
return isMobile;
|
|
31
|
+
},
|
|
32
|
+
apply: false,
|
|
33
|
+
priority: 50,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rule to skip layouts based on specific headers
|
|
38
|
+
* Requirements: 4.3
|
|
39
|
+
*/
|
|
40
|
+
static readonly HEADER_BASED_SKIP: LayoutRule = {
|
|
41
|
+
matches: (route: RouteInfo): boolean => {
|
|
42
|
+
const skipLayout = route.headers.get('x-skip-layout');
|
|
43
|
+
return skipLayout === 'true' || skipLayout === '1';
|
|
44
|
+
},
|
|
45
|
+
apply: false,
|
|
46
|
+
priority: 90,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rule to apply admin layouts only for admin routes
|
|
51
|
+
* Requirements: 4.3
|
|
52
|
+
*/
|
|
53
|
+
static readonly ADMIN_LAYOUT_RESTRICTION: LayoutRule = {
|
|
54
|
+
matches: (route: RouteInfo, layoutPath?: string): boolean => {
|
|
55
|
+
// Only restrict admin layouts — if no layoutPath or not an admin layout, don't match
|
|
56
|
+
if (!layoutPath?.includes('/admin/')) return false;
|
|
57
|
+
// Admin layout should only apply to admin routes
|
|
58
|
+
return !route.path.startsWith('/admin/');
|
|
59
|
+
},
|
|
60
|
+
apply: false,
|
|
61
|
+
priority: 60,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
static getAllRules(): LayoutRule[] {
|
|
65
|
+
return [
|
|
66
|
+
this.API_ROUTES_SKIP_LAYOUTS,
|
|
67
|
+
this.MOBILE_LAYOUT_DETECTION,
|
|
68
|
+
this.HEADER_BASED_SKIP,
|
|
69
|
+
this.ADMIN_LAYOUT_RESTRICTION,
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Layout matcher class that handles conditional layout rendering based on rules
|
|
76
|
+
* Requirements: 4.1, 4.2, 4.3, 4.4, 4.5
|
|
77
|
+
*/
|
|
78
|
+
export class LayoutMatcher {
|
|
79
|
+
private rules: LayoutRule[] = [];
|
|
80
|
+
private readonly developmentMode: boolean;
|
|
81
|
+
|
|
82
|
+
constructor(options: { developmentMode?: boolean } = {}) {
|
|
83
|
+
this.developmentMode = options.developmentMode || false;
|
|
84
|
+
this.addBuiltInRules();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Add a new layout rule
|
|
89
|
+
* Requirements: 4.3
|
|
90
|
+
*/
|
|
91
|
+
addRule(rule: LayoutRule): void {
|
|
92
|
+
if (!rule.matches || typeof rule.matches !== 'function') {
|
|
93
|
+
throw new Error('Layout rule must have a valid matches function');
|
|
94
|
+
}
|
|
95
|
+
if (typeof rule.apply !== 'boolean') {
|
|
96
|
+
throw new TypeError('Layout rule must have a boolean apply property');
|
|
97
|
+
}
|
|
98
|
+
if (typeof rule.priority !== 'number') {
|
|
99
|
+
throw new TypeError('Layout rule must have a numeric priority');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.rules.push(rule);
|
|
103
|
+
this.sortRulesByPriority();
|
|
104
|
+
|
|
105
|
+
if (this.developmentMode) {
|
|
106
|
+
console.log(`[LayoutMatcher] Added rule with priority ${rule.priority}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove a layout rule
|
|
112
|
+
* Requirements: 4.3
|
|
113
|
+
*/
|
|
114
|
+
removeRule(rule: LayoutRule): void {
|
|
115
|
+
const index = this.rules.indexOf(rule);
|
|
116
|
+
if (index > -1) {
|
|
117
|
+
this.rules.splice(index, 1);
|
|
118
|
+
if (this.developmentMode) {
|
|
119
|
+
console.log(`[LayoutMatcher] Removed rule with priority ${rule.priority}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if a layout should be applied based on all rules
|
|
126
|
+
* Requirements: 4.1, 4.2, 4.3, 4.4, 4.5
|
|
127
|
+
*/
|
|
128
|
+
shouldApplyLayout(layoutPath: string, route: RouteInfo): boolean {
|
|
129
|
+
try {
|
|
130
|
+
const matchingRules = this.getMatchingRules(route, layoutPath);
|
|
131
|
+
|
|
132
|
+
if (matchingRules.length === 0) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = this.resolveRuleConflicts(matchingRules);
|
|
137
|
+
|
|
138
|
+
if (this.developmentMode) {
|
|
139
|
+
console.log(
|
|
140
|
+
`[LayoutMatcher] Layout ${layoutPath} for route ${route.path}: ${result ? 'APPLY' : 'SKIP'} ` +
|
|
141
|
+
`(${matchingRules.length} rules matched)`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return result;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (this.developmentMode) {
|
|
148
|
+
console.warn(
|
|
149
|
+
`[LayoutMatcher] Error evaluating rules for layout ${layoutPath}: ${
|
|
150
|
+
error instanceof Error ? error.message : String(error)
|
|
151
|
+
}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getRules(): LayoutRule[] {
|
|
159
|
+
return [...this.rules];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
clearRules(): void {
|
|
163
|
+
this.rules = [];
|
|
164
|
+
if (this.developmentMode) {
|
|
165
|
+
console.log('[LayoutMatcher] Cleared all rules');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private addBuiltInRules(): void {
|
|
170
|
+
for (const rule of BuiltInLayoutRules.getAllRules()) {
|
|
171
|
+
this.rules.push(rule);
|
|
172
|
+
}
|
|
173
|
+
this.sortRulesByPriority();
|
|
174
|
+
if (this.developmentMode) {
|
|
175
|
+
console.log(`[LayoutMatcher] Added ${this.rules.length} built-in rules`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private sortRulesByPriority(): void {
|
|
180
|
+
this.rules.sort((a, b) => b.priority - a.priority);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private getMatchingRules(route: RouteInfo, layoutPath?: string): LayoutRule[] {
|
|
184
|
+
const matchingRules: LayoutRule[] = [];
|
|
185
|
+
for (const rule of this.rules) {
|
|
186
|
+
try {
|
|
187
|
+
if (rule.matches(route, layoutPath)) {
|
|
188
|
+
matchingRules.push(rule);
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (this.developmentMode) {
|
|
192
|
+
console.warn(
|
|
193
|
+
`[LayoutMatcher] Error in rule evaluation: ${error instanceof Error ? error.message : String(error)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return matchingRules;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private resolveRuleConflicts(matchingRules: LayoutRule[]): boolean {
|
|
202
|
+
if (matchingRules.length === 0) return true;
|
|
203
|
+
if (matchingRules.length === 1) return matchingRules[0].apply;
|
|
204
|
+
|
|
205
|
+
const rulesByPriority = new Map<number, LayoutRule[]>();
|
|
206
|
+
for (const rule of matchingRules) {
|
|
207
|
+
if (!rulesByPriority.has(rule.priority)) {
|
|
208
|
+
rulesByPriority.set(rule.priority, []);
|
|
209
|
+
}
|
|
210
|
+
rulesByPriority.get(rule.priority)!.push(rule);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const priorities = Array.from(rulesByPriority.keys()).sort((a, b) => b - a);
|
|
214
|
+
const highestPriorityRules = rulesByPriority.get(priorities[0])!;
|
|
215
|
+
|
|
216
|
+
if (highestPriorityRules.length === 1) {
|
|
217
|
+
return highestPriorityRules[0].apply;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return this.resolveEqualPriorityConflicts(highestPriorityRules);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private resolveEqualPriorityConflicts(rules: LayoutRule[]): boolean {
|
|
224
|
+
const applyCount = rules.filter(rule => rule.apply).length;
|
|
225
|
+
const skipCount = rules.filter(rule => !rule.apply).length;
|
|
226
|
+
|
|
227
|
+
if (skipCount > applyCount) {
|
|
228
|
+
if (this.developmentMode) console.log(`[LayoutMatcher] Conflict resolution: SKIP`);
|
|
229
|
+
return false;
|
|
230
|
+
} else if (applyCount > skipCount) {
|
|
231
|
+
if (this.developmentMode) console.log(`[LayoutMatcher] Conflict resolution: APPLY`);
|
|
232
|
+
return true;
|
|
233
|
+
} else {
|
|
234
|
+
if (this.developmentMode) console.log(`[LayoutMatcher] Conflict resolution: SKIP (tie-breaker)`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a custom rule
|
|
241
|
+
* Requirements: 4.3
|
|
242
|
+
*/
|
|
243
|
+
static createCustomRule(matcher: (route: RouteInfo) => boolean, apply: boolean, priority: number = 10): LayoutRule {
|
|
244
|
+
return { matches: matcher, apply, priority };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
static createPathRule(pathPattern: string | RegExp, apply: boolean, priority: number = 10): LayoutRule {
|
|
248
|
+
const matches =
|
|
249
|
+
typeof pathPattern === 'string'
|
|
250
|
+
? (route: RouteInfo) => route.path.includes(pathPattern)
|
|
251
|
+
: (route: RouteInfo) => pathPattern.test(route.path);
|
|
252
|
+
return { matches, apply, priority };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
static createHeaderRule(
|
|
256
|
+
headerName: string,
|
|
257
|
+
headerValue: string | RegExp,
|
|
258
|
+
apply: boolean,
|
|
259
|
+
priority: number = 10,
|
|
260
|
+
): LayoutRule {
|
|
261
|
+
return {
|
|
262
|
+
matches: (route: RouteInfo) => {
|
|
263
|
+
const val = route.headers.get(headerName.toLowerCase());
|
|
264
|
+
if (!val) return false;
|
|
265
|
+
return typeof headerValue === 'string' ? val === headerValue : headerValue.test(val);
|
|
266
|
+
},
|
|
267
|
+
apply,
|
|
268
|
+
priority,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
static createMethodRule(methods: string | string[], apply: boolean, priority: number = 10): LayoutRule {
|
|
273
|
+
const normalizedMethods = new Set((Array.isArray(methods) ? methods : [methods]).map(m => m.toUpperCase()));
|
|
274
|
+
return {
|
|
275
|
+
matches: (route: RouteInfo) => normalizedMethods.has(route.method.toUpperCase()),
|
|
276
|
+
apply,
|
|
277
|
+
priority,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
getDebugInfo(
|
|
282
|
+
layoutPath: string,
|
|
283
|
+
route: RouteInfo,
|
|
284
|
+
): {
|
|
285
|
+
totalRules: number;
|
|
286
|
+
matchingRules: Array<{ priority: number; apply: boolean }>;
|
|
287
|
+
finalDecision: boolean;
|
|
288
|
+
conflictResolution?: string;
|
|
289
|
+
} {
|
|
290
|
+
const matchingRules = this.getMatchingRules(route, layoutPath);
|
|
291
|
+
const finalDecision = this.shouldApplyLayout(layoutPath, route);
|
|
292
|
+
return {
|
|
293
|
+
totalRules: this.rules.length,
|
|
294
|
+
matchingRules: matchingRules.map(rule => ({ priority: rule.priority, apply: rule.apply })),
|
|
295
|
+
finalDecision,
|
|
296
|
+
conflictResolution: matchingRules.length > 1 ? 'priority-based' : 'single-rule',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|