@uniweb/core 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
package/src/block.js CHANGED
@@ -62,6 +62,10 @@ export default class Block {
62
62
  // Populated during render for components with inheritData
63
63
  this.cascadedData = blockData.cascadedData || {}
64
64
 
65
+ // Dynamic route context (params from URL matching)
66
+ // Set when accessing a dynamic page like /blog/:slug -> /blog/my-post
67
+ this.dynamicContext = blockData.dynamicContext || null
68
+
65
69
  // State management (dynamic, can change at runtime)
66
70
  this.startState = null
67
71
  this.state = null
@@ -350,6 +354,80 @@ export default class Block {
350
354
  return [state, (newState) => setState((this.state = newState))]
351
355
  }
352
356
 
357
+ // ─────────────────────────────────────────────────────────────────
358
+ // Dynamic Route Data Resolution
359
+ // ─────────────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * Get dynamic route context (params from URL matching)
363
+ * @returns {Object|null} Dynamic context with params, or null if not a dynamic page
364
+ *
365
+ * @example
366
+ * // For route /blog/:slug matched against /blog/my-post
367
+ * block.getDynamicContext()
368
+ * // { templateRoute: '/blog/:slug', params: { slug: 'my-post' }, paramName: 'slug', paramValue: 'my-post' }
369
+ */
370
+ getDynamicContext() {
371
+ return this.dynamicContext
372
+ }
373
+
374
+ /**
375
+ * Get the current item from cascaded data using dynamic route params
376
+ * Looks up the item in cascadedData that matches the URL param value
377
+ *
378
+ * @param {string} [schema] - Schema name to look up (e.g., 'articles'). If omitted, uses parentSchema from dynamicContext.
379
+ * @returns {Object|null} The matched item, or null if not found
380
+ *
381
+ * @example
382
+ * // URL: /blog/my-post, cascadedData: { articles: [{slug: 'my-post', title: 'My Post'}, ...] }
383
+ * block.getCurrentItem('articles')
384
+ * // { slug: 'my-post', title: 'My Post', ... }
385
+ */
386
+ getCurrentItem(schema) {
387
+ const ctx = this.dynamicContext
388
+ if (!ctx) return null
389
+
390
+ const { paramName, paramValue } = ctx
391
+
392
+ // If schema not provided, try to infer from cascadedData keys
393
+ const lookupSchema = schema || this._inferSchema()
394
+ if (!lookupSchema) return null
395
+
396
+ const items = this.cascadedData[lookupSchema]
397
+ if (!Array.isArray(items)) return null
398
+
399
+ // Find item where the param field matches the URL value
400
+ return items.find(item => String(item[paramName]) === String(paramValue)) || null
401
+ }
402
+
403
+ /**
404
+ * Get all items from cascaded data for the dynamic route's schema
405
+ *
406
+ * @param {string} [schema] - Schema name to look up. If omitted, uses parentSchema from dynamicContext.
407
+ * @returns {Array} Array of items, or empty array if not found
408
+ */
409
+ getAllItems(schema) {
410
+ const lookupSchema = schema || this._inferSchema()
411
+ if (!lookupSchema) return []
412
+
413
+ const items = this.cascadedData[lookupSchema]
414
+ return Array.isArray(items) ? items : []
415
+ }
416
+
417
+ /**
418
+ * Infer the schema name from cascaded data keys
419
+ * Looks for the first array in cascadedData
420
+ * @private
421
+ */
422
+ _inferSchema() {
423
+ for (const key of Object.keys(this.cascadedData)) {
424
+ if (Array.isArray(this.cascadedData[key])) {
425
+ return key
426
+ }
427
+ }
428
+ return null
429
+ }
430
+
353
431
  /**
354
432
  * Parse nested links structure
355
433
  */
package/src/page.js CHANGED
@@ -8,7 +8,15 @@
8
8
  import Block from './block.js'
9
9
 
10
10
  export default class Page {
11
- constructor(pageData, id, website, pageHeader, pageFooter, pageLeft, pageRight) {
11
+ constructor(
12
+ pageData,
13
+ id,
14
+ website,
15
+ pageHeader,
16
+ pageFooter,
17
+ pageLeft,
18
+ pageRight,
19
+ ) {
12
20
  this.id = id
13
21
  this.route = pageData.route
14
22
  this.isIndex = pageData.isIndex || false // True if this page is the index for its parent route
@@ -32,7 +40,7 @@ export default class Page {
32
40
  right: pageData.layout?.right !== false,
33
41
  // Aliases for backwards compatibility
34
42
  leftPanel: pageData.layout?.left !== false,
35
- rightPanel: pageData.layout?.right !== false
43
+ rightPanel: pageData.layout?.right !== false,
36
44
  }
37
45
 
38
46
  // SEO configuration
@@ -43,7 +51,7 @@ export default class Page {
43
51
  ogDescription: pageData.seo?.ogDescription || null,
44
52
  canonical: pageData.seo?.canonical || null,
45
53
  changefreq: pageData.seo?.changefreq || null,
46
- priority: pageData.seo?.priority || null
54
+ priority: pageData.seo?.priority || null,
47
55
  }
48
56
 
49
57
  // Child pages (for nested hierarchy) - populated by Website
@@ -55,13 +63,16 @@ export default class Page {
55
63
  // Scroll position memory (for navigation restoration)
56
64
  this.scrollY = 0
57
65
 
66
+ // Dynamic route context (for pages created from dynamic routes like /blog/:slug)
67
+ this.dynamicContext = pageData.dynamicContext || null
68
+
58
69
  // Build block groups for all layout areas
59
70
  this.pageBlocks = this.buildPageBlocks(
60
71
  pageData.sections,
61
72
  pageHeader?.sections,
62
73
  pageFooter?.sections,
63
74
  pageLeft?.sections,
64
- pageRight?.sections
75
+ pageRight?.sections,
65
76
  )
66
77
  }
67
78
 
@@ -80,11 +91,44 @@ export default class Page {
80
91
  title: this.seo.ogTitle || this.title,
81
92
  description: this.seo.ogDescription || this.description,
82
93
  image: this.seo.image,
83
- url: this.route
84
- }
94
+ url: this.route,
95
+ },
85
96
  }
86
97
  }
87
98
 
99
+ // ─────────────────────────────────────────────────────────────────
100
+ // Dynamic Route Support
101
+ // ─────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Check if this is a dynamic page (created from a route pattern like /blog/:slug)
105
+ * @returns {boolean}
106
+ */
107
+ isDynamicPage() {
108
+ return this.dynamicContext !== null
109
+ }
110
+
111
+ /**
112
+ * Get dynamic route context
113
+ * @returns {Object|null} Dynamic context with params, or null if not a dynamic page
114
+ *
115
+ * @example
116
+ * // For route /blog/:slug matched against /blog/my-post
117
+ * page.getDynamicContext()
118
+ * // { templateRoute: '/blog/:slug', params: { slug: 'my-post' }, paramName: 'slug', paramValue: 'my-post' }
119
+ */
120
+ getDynamicContext() {
121
+ return this.dynamicContext
122
+ }
123
+
124
+ /**
125
+ * Get the URL param value for dynamic routes
126
+ * @returns {string|null} The param value (e.g., 'my-post' for /blog/my-post), or null
127
+ */
128
+ getDynamicParam() {
129
+ return this.dynamicContext?.paramValue || null
130
+ }
131
+
88
132
  /**
89
133
  * Build the page block structure for all layout areas.
90
134
  * Each area can have multiple sections/blocks.
@@ -117,7 +161,7 @@ export default class Page {
117
161
  body: bodyBlocks,
118
162
  footer: buildBlocks(footer, 'footer'),
119
163
  left: buildBlocks(left, 'left'),
120
- right: buildBlocks(right, 'right')
164
+ right: buildBlocks(right, 'right'),
121
165
  }
122
166
  }
123
167
 
@@ -279,7 +323,7 @@ export default class Page {
279
323
  ...this.pageBlocks.body,
280
324
  ...(this.pageBlocks.footer || []),
281
325
  ...(this.pageBlocks.left || []),
282
- ...(this.pageBlocks.right || [])
326
+ ...(this.pageBlocks.right || []),
283
327
  ]
284
328
 
285
329
  for (const block of allBlocks) {
@@ -422,29 +466,31 @@ export default class Page {
422
466
 
423
467
  /**
424
468
  * Get route without leading/trailing slashes.
425
- * Useful for route comparisons.
469
+ * Delegates to Website.normalizeRoute() for consistent normalization.
426
470
  *
427
471
  * @returns {string} Normalized route (e.g., 'docs/getting-started')
428
472
  */
429
473
  getNormalizedRoute() {
430
- return (this.route || '').replace(/^\//, '').replace(/\/$/, '')
474
+ return this.website.normalizeRoute(this.route)
431
475
  }
432
476
 
433
477
  /**
434
478
  * Check if this page matches the given route exactly.
479
+ * Delegates to Website.isRouteActive() for consistent comparison.
435
480
  *
436
- * @param {string} route - Normalized route (no leading/trailing slashes)
481
+ * @param {string} currentRoute - Current route to compare against
437
482
  * @returns {boolean} True if this page's route matches
438
483
  */
439
- isActiveFor(route) {
440
- return this.getNormalizedRoute() === route
484
+ isActiveFor(currentRoute) {
485
+ return this.website.isRouteActive(this.route, currentRoute)
441
486
  }
442
487
 
443
488
  /**
444
489
  * Check if this page or any descendant matches the given route.
445
490
  * Useful for highlighting parent nav items when a child page is active.
491
+ * Delegates to Website.isRouteActiveOrAncestor() for consistent logic.
446
492
  *
447
- * @param {string} route - Normalized route (no leading/trailing slashes)
493
+ * @param {string} currentRoute - Current route to compare against
448
494
  * @returns {boolean} True if this page or a descendant is active
449
495
  *
450
496
  * @example
@@ -452,12 +498,7 @@ export default class Page {
452
498
  * // Current route: 'docs/getting-started/installation'
453
499
  * page.isActiveOrAncestor('docs/getting-started/installation') // true
454
500
  */
455
- isActiveOrAncestor(route) {
456
- const pageRoute = this.getNormalizedRoute()
457
- if (pageRoute === route) return true
458
- // Check if route starts with this page's route followed by /
459
- // Handle empty pageRoute (root) specially
460
- if (pageRoute === '') return true // Root is ancestor of all
461
- return route.startsWith(pageRoute + '/')
501
+ isActiveOrAncestor(currentRoute) {
502
+ return this.website.isRouteActiveOrAncestor(this.route, currentRoute)
462
503
  }
463
504
  }
package/src/website.js CHANGED
@@ -25,7 +25,7 @@ const LOCALE_NAMES = {
25
25
 
26
26
  export default class Website {
27
27
  constructor(websiteData) {
28
- const { pages = [], theme = {}, config = {}, header, footer, left, right } = websiteData
28
+ const { pages = [], theme = {}, config = {}, header, footer, left, right, notFound } = websiteData
29
29
 
30
30
  // Site metadata
31
31
  this.name = config.name || ''
@@ -40,14 +40,29 @@ export default class Website {
40
40
  this.leftPage = left || pages.find((p) => p.route === '/@left') || null
41
41
  this.rightPage = right || pages.find((p) => p.route === '/@right') || null
42
42
 
43
+ // Store 404 page (for SPA routing)
44
+ // Convention: pages/404/ directory
45
+ this.notFoundPage = notFound || pages.find((p) => p.route === '/404') || null
46
+
43
47
  // Filter out special pages from regular pages array
44
- const specialRoutes = ['/@header', '/@footer', '/@left', '/@right']
45
- this.pages = pages
46
- .filter((page) => !specialRoutes.includes(page.route))
47
- .map(
48
- (page, index) =>
49
- new Page(page, index, this, this.headerPage, this.footerPage, this.leftPage, this.rightPage)
50
- )
48
+ const specialRoutes = ['/@header', '/@footer', '/@left', '/@right', '/404']
49
+ const regularPages = pages.filter((page) => !specialRoutes.includes(page.route))
50
+
51
+ // Store original page data for dynamic pages (needed to create instances on-demand)
52
+ this._dynamicPageData = new Map()
53
+ for (const pageData of regularPages) {
54
+ if (pageData.isDynamic || pageData.route?.includes(':')) {
55
+ this._dynamicPageData.set(pageData.route, pageData)
56
+ }
57
+ }
58
+
59
+ // Cache for dynamically created page instances
60
+ this._dynamicPageCache = new Map()
61
+
62
+ this.pages = regularPages.map(
63
+ (page, index) =>
64
+ new Page(page, index, this, this.headerPage, this.footerPage, this.leftPage, this.rightPage)
65
+ )
51
66
 
52
67
  // Build parent-child relationships based on route structure
53
68
  this.buildPageHierarchy()
@@ -157,17 +172,245 @@ export default class Website {
157
172
 
158
173
  /**
159
174
  * Get page by route
160
- * Matches both actual routes and nav routes (for index pages)
161
- * @param {string} route
175
+ * Matches in priority order:
176
+ * 1. Exact match on actual route
177
+ * 2. Index page nav route match
178
+ * 3. Dynamic route pattern match (e.g., /blog/:slug matches /blog/my-post)
179
+ *
180
+ * @param {string} route - The route to find
162
181
  * @returns {Page|undefined}
163
182
  */
164
183
  getPage(route) {
165
- // First try exact match on actual route
184
+ // Priority 1: Exact match on actual route
166
185
  const exactMatch = this.pages.find((page) => page.route === route)
167
186
  if (exactMatch) return exactMatch
168
187
 
169
- // Then try matching nav route (for index pages accessible at parent route)
170
- return this.pages.find((page) => page.isIndex && page.getNavRoute() === route)
188
+ // Priority 2: Index page nav route match
189
+ const indexMatch = this.pages.find((page) => page.isIndex && page.getNavRoute() === route)
190
+ if (indexMatch) return indexMatch
191
+
192
+ // Priority 3: Dynamic route pattern matching
193
+ // Check cache first
194
+ if (this._dynamicPageCache.has(route)) {
195
+ return this._dynamicPageCache.get(route)
196
+ }
197
+
198
+ // Try to match against dynamic route patterns
199
+ for (const page of this.pages) {
200
+ // Check if this is a dynamic page (has :param in route)
201
+ if (!page.route.includes(':')) continue
202
+
203
+ const match = this._matchDynamicRoute(page.route, route)
204
+ if (match) {
205
+ // Create a dynamic page instance with the concrete route and params
206
+ const dynamicPage = this._createDynamicPage(page, route, match.params)
207
+ if (dynamicPage) {
208
+ // Cache for future requests
209
+ this._dynamicPageCache.set(route, dynamicPage)
210
+ return dynamicPage
211
+ }
212
+ }
213
+ }
214
+
215
+ return undefined
216
+ }
217
+
218
+ /**
219
+ * Match a dynamic route pattern against a concrete path
220
+ * E.g., /blog/:slug matches /blog/my-post => { params: { slug: 'my-post' } }
221
+ *
222
+ * @private
223
+ * @param {string} pattern - Route pattern with :param placeholders
224
+ * @param {string} path - Actual path to match
225
+ * @returns {Object|null} Match result with params, or null if no match
226
+ */
227
+ _matchDynamicRoute(pattern, path) {
228
+ // Extract param names and build regex
229
+ const paramNames = []
230
+ const regexStr = pattern
231
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars except :
232
+ .replace(/:(\w+)/g, (_, paramName) => {
233
+ paramNames.push(paramName)
234
+ return '([^/]+)' // Capture anything except /
235
+ })
236
+
237
+ const regex = new RegExp(`^${regexStr}$`)
238
+ const match = path.match(regex)
239
+
240
+ if (!match) return null
241
+
242
+ // Build params object
243
+ const params = {}
244
+ paramNames.forEach((name, i) => {
245
+ params[name] = decodeURIComponent(match[i + 1])
246
+ })
247
+
248
+ return { params }
249
+ }
250
+
251
+ /**
252
+ * Create a dynamic page instance with concrete route and params
253
+ *
254
+ * @private
255
+ * @param {Page} templatePage - The template page with :param route
256
+ * @param {string} concreteRoute - The actual route (e.g., /blog/my-post)
257
+ * @param {Object} params - Matched params (e.g., { slug: 'my-post' })
258
+ * @returns {Page|null} New page instance or null
259
+ */
260
+ _createDynamicPage(templatePage, concreteRoute, params) {
261
+ // Get the original page data
262
+ const originalData = this._dynamicPageData.get(templatePage.route)
263
+ if (!originalData) return null
264
+
265
+ // Deep clone the page data
266
+ const pageData = JSON.parse(JSON.stringify(originalData))
267
+
268
+ // Update with concrete route and dynamic context
269
+ pageData.route = concreteRoute
270
+ pageData.isDynamic = false // No longer a template
271
+
272
+ const paramName = Object.keys(params)[0]
273
+ const paramValue = Object.values(params)[0]
274
+ const pluralSchema = originalData.parentSchema // e.g., 'articles'
275
+ const singularSchema = this._singularize(pluralSchema) // e.g., 'article'
276
+
277
+ // Store dynamic context for components to access
278
+ pageData.dynamicContext = {
279
+ templateRoute: templatePage.route,
280
+ params,
281
+ paramName,
282
+ paramValue,
283
+ schema: pluralSchema,
284
+ singularSchema,
285
+ }
286
+
287
+ // Get the parent page's data to find the items array
288
+ // Parent route is the template route without the :param suffix
289
+ const parentRoute = templatePage.route.replace(/\/:[\w]+$/, '') || '/'
290
+ const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
291
+
292
+ // Get items from parent's cascaded data
293
+ let items = []
294
+ let currentItem = null
295
+
296
+ if (parentPage && pluralSchema) {
297
+ // Get items from parent page's first section's cascadedData
298
+ // This is where the page-level fetch stores its data
299
+ const firstSection = parentPage.pageBlocks?.body?.[0]
300
+ if (firstSection) {
301
+ items = firstSection.cascadedData?.[pluralSchema] || []
302
+ }
303
+
304
+ // Find the current item using the param
305
+ if (items.length > 0) {
306
+ currentItem = items.find(item => String(item[paramName]) === String(paramValue))
307
+ }
308
+ }
309
+
310
+ // Store items in dynamic context for Block.getCurrentItem() / getAllItems()
311
+ pageData.dynamicContext.currentItem = currentItem
312
+ pageData.dynamicContext.allItems = items
313
+
314
+ // Inject cascaded data into sections for components with inheritData
315
+ // This provides both singular (article) and plural (articles) data
316
+ const cascadedData = {}
317
+ if (currentItem && singularSchema) {
318
+ cascadedData[singularSchema] = currentItem
319
+ }
320
+ if (items.length > 0 && pluralSchema) {
321
+ cascadedData[pluralSchema] = items
322
+ }
323
+
324
+ this._injectDynamicData(pageData.sections, cascadedData, pageData.dynamicContext)
325
+
326
+ // Update page metadata from current item if available
327
+ if (currentItem) {
328
+ if (currentItem.title) pageData.title = currentItem.title
329
+ if (currentItem.description || currentItem.excerpt) {
330
+ pageData.description = currentItem.description || currentItem.excerpt
331
+ }
332
+ }
333
+
334
+ // Create the page instance
335
+ const dynamicPage = new Page(
336
+ pageData,
337
+ `dynamic-${concreteRoute}`,
338
+ this,
339
+ this.headerPage,
340
+ this.footerPage,
341
+ this.leftPage,
342
+ this.rightPage
343
+ )
344
+
345
+ // Copy parent reference from template
346
+ dynamicPage.parent = templatePage.parent
347
+
348
+ return dynamicPage
349
+ }
350
+
351
+ /**
352
+ * Singularize a plural schema name
353
+ * @private
354
+ */
355
+ _singularize(name) {
356
+ if (!name) return name
357
+ // Common irregular plurals
358
+ const irregulars = {
359
+ people: 'person',
360
+ children: 'child',
361
+ men: 'men',
362
+ women: 'woman',
363
+ series: 'series',
364
+ }
365
+ if (irregulars[name]) return irregulars[name]
366
+ // -ies → -y (categories → category)
367
+ if (name.endsWith('ies')) return name.slice(0, -3) + 'y'
368
+ // -es endings that should only remove 's' (not 'es')
369
+ // e.g., articles → article, courses → course
370
+ if (name.endsWith('es')) {
371
+ // Check if the base word ends in a consonant that requires 'es' plural
372
+ // (boxes, dishes, classes, heroes) vs just 's' plural (articles, courses)
373
+ const base = name.slice(0, -2)
374
+ const lastChar = base.slice(-1)
375
+ // If base ends in s, x, z, ch, sh - these need 'es' for plural, so remove 'es'
376
+ if (['s', 'x', 'z'].includes(lastChar) || base.endsWith('ch') || base.endsWith('sh')) {
377
+ return base
378
+ }
379
+ // Otherwise just remove 's' (articles → article)
380
+ return name.slice(0, -1)
381
+ }
382
+ // Regular -s plurals
383
+ if (name.endsWith('s')) return name.slice(0, -1)
384
+ return name
385
+ }
386
+
387
+ /**
388
+ * Inject dynamic route data into sections for components with inheritData
389
+ * This provides both the current item (singular) and all items (plural)
390
+ *
391
+ * @private
392
+ * @param {Array} sections - Sections to update
393
+ * @param {Object} cascadedData - Data to inject { article: {...}, articles: [...] }
394
+ * @param {Object} dynamicContext - Dynamic route context
395
+ */
396
+ _injectDynamicData(sections, cascadedData, dynamicContext) {
397
+ if (!sections || !Array.isArray(sections)) return
398
+
399
+ for (const section of sections) {
400
+ // Merge cascaded data into section's existing cascadedData
401
+ section.cascadedData = {
402
+ ...(section.cascadedData || {}),
403
+ ...cascadedData,
404
+ }
405
+
406
+ // Also set dynamic context for Block.getDynamicContext()
407
+ section.dynamicContext = dynamicContext
408
+
409
+ // Recurse into subsections
410
+ if (section.subsections && section.subsections.length > 0) {
411
+ this._injectDynamicData(section.subsections, cascadedData, dynamicContext)
412
+ }
413
+ }
171
414
  }
172
415
 
173
416
  /**
@@ -530,6 +773,14 @@ export default class Website {
530
773
  return this.getPageHierarchy({ nested: false, includeHidden })
531
774
  }
532
775
 
776
+ /**
777
+ * Get the 404 (not found) page if defined
778
+ * @returns {Page|null} The 404 page or null
779
+ */
780
+ getNotFoundPage() {
781
+ return this.notFoundPage
782
+ }
783
+
533
784
  // ─────────────────────────────────────────────────────────────────
534
785
  // Active Route API (for navigation components)
535
786
  // ─────────────────────────────────────────────────────────────────
@@ -560,4 +811,64 @@ export default class Website {
560
811
  getActiveRootSegment() {
561
812
  return this.getActiveRoute().split('/')[0]
562
813
  }
814
+
815
+ /**
816
+ * Normalize a route by removing leading/trailing slashes.
817
+ * This is the single source of truth for route normalization.
818
+ *
819
+ * @param {string} route - Route to normalize
820
+ * @returns {string} Normalized route (e.g., 'docs/getting-started')
821
+ *
822
+ * @example
823
+ * website.normalizeRoute('/docs/guide/') // 'docs/guide'
824
+ * website.normalizeRoute('about') // 'about'
825
+ * website.normalizeRoute('/') // ''
826
+ */
827
+ normalizeRoute(route) {
828
+ return (route || '').replace(/^\/+/, '').replace(/\/+$/, '')
829
+ }
830
+
831
+ /**
832
+ * Check if a target route matches the current route exactly.
833
+ *
834
+ * @param {string} targetRoute - Route to check (will be normalized)
835
+ * @param {string} currentRoute - Current route (will be normalized)
836
+ * @returns {boolean} True if routes match exactly
837
+ *
838
+ * @example
839
+ * website.isRouteActive('/about', '/about') // true
840
+ * website.isRouteActive('/about', '/about/team') // false
841
+ */
842
+ isRouteActive(targetRoute, currentRoute) {
843
+ return this.normalizeRoute(targetRoute) === this.normalizeRoute(currentRoute)
844
+ }
845
+
846
+ /**
847
+ * Check if a target route matches the current route or is an ancestor of it.
848
+ * Used for navigation highlighting where parent items should be highlighted
849
+ * when a child page is active.
850
+ *
851
+ * @param {string} targetRoute - Route to check (will be normalized)
852
+ * @param {string} currentRoute - Current route (will be normalized)
853
+ * @returns {boolean} True if target matches current or is an ancestor
854
+ *
855
+ * @example
856
+ * website.isRouteActiveOrAncestor('/docs', '/docs') // true (exact)
857
+ * website.isRouteActiveOrAncestor('/docs', '/docs/guide') // true (ancestor)
858
+ * website.isRouteActiveOrAncestor('/about', '/docs/guide') // false
859
+ * website.isRouteActiveOrAncestor('/', '/docs') // false (root is not ancestor of all)
860
+ */
861
+ isRouteActiveOrAncestor(targetRoute, currentRoute) {
862
+ const target = this.normalizeRoute(targetRoute)
863
+ const current = this.normalizeRoute(currentRoute)
864
+
865
+ // Exact match
866
+ if (target === current) return true
867
+
868
+ // Empty target (root) is not considered ancestor of everything
869
+ if (target === '') return false
870
+
871
+ // Check if current starts with target followed by /
872
+ return current.startsWith(target + '/')
873
+ }
563
874
  }