@uniweb/core 0.1.13 → 0.1.14

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.14",
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
@@ -42,12 +42,23 @@ export default class Website {
42
42
 
43
43
  // Filter out special pages from regular pages array
44
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
- )
45
+ const regularPages = pages.filter((page) => !specialRoutes.includes(page.route))
46
+
47
+ // Store original page data for dynamic pages (needed to create instances on-demand)
48
+ this._dynamicPageData = new Map()
49
+ for (const pageData of regularPages) {
50
+ if (pageData.isDynamic || pageData.route?.includes(':')) {
51
+ this._dynamicPageData.set(pageData.route, pageData)
52
+ }
53
+ }
54
+
55
+ // Cache for dynamically created page instances
56
+ this._dynamicPageCache = new Map()
57
+
58
+ this.pages = regularPages.map(
59
+ (page, index) =>
60
+ new Page(page, index, this, this.headerPage, this.footerPage, this.leftPage, this.rightPage)
61
+ )
51
62
 
52
63
  // Build parent-child relationships based on route structure
53
64
  this.buildPageHierarchy()
@@ -157,17 +168,245 @@ export default class Website {
157
168
 
158
169
  /**
159
170
  * Get page by route
160
- * Matches both actual routes and nav routes (for index pages)
161
- * @param {string} route
171
+ * Matches in priority order:
172
+ * 1. Exact match on actual route
173
+ * 2. Index page nav route match
174
+ * 3. Dynamic route pattern match (e.g., /blog/:slug matches /blog/my-post)
175
+ *
176
+ * @param {string} route - The route to find
162
177
  * @returns {Page|undefined}
163
178
  */
164
179
  getPage(route) {
165
- // First try exact match on actual route
180
+ // Priority 1: Exact match on actual route
166
181
  const exactMatch = this.pages.find((page) => page.route === route)
167
182
  if (exactMatch) return exactMatch
168
183
 
169
- // Then try matching nav route (for index pages accessible at parent route)
170
- return this.pages.find((page) => page.isIndex && page.getNavRoute() === route)
184
+ // Priority 2: Index page nav route match
185
+ const indexMatch = this.pages.find((page) => page.isIndex && page.getNavRoute() === route)
186
+ if (indexMatch) return indexMatch
187
+
188
+ // Priority 3: Dynamic route pattern matching
189
+ // Check cache first
190
+ if (this._dynamicPageCache.has(route)) {
191
+ return this._dynamicPageCache.get(route)
192
+ }
193
+
194
+ // Try to match against dynamic route patterns
195
+ for (const page of this.pages) {
196
+ // Check if this is a dynamic page (has :param in route)
197
+ if (!page.route.includes(':')) continue
198
+
199
+ const match = this._matchDynamicRoute(page.route, route)
200
+ if (match) {
201
+ // Create a dynamic page instance with the concrete route and params
202
+ const dynamicPage = this._createDynamicPage(page, route, match.params)
203
+ if (dynamicPage) {
204
+ // Cache for future requests
205
+ this._dynamicPageCache.set(route, dynamicPage)
206
+ return dynamicPage
207
+ }
208
+ }
209
+ }
210
+
211
+ return undefined
212
+ }
213
+
214
+ /**
215
+ * Match a dynamic route pattern against a concrete path
216
+ * E.g., /blog/:slug matches /blog/my-post => { params: { slug: 'my-post' } }
217
+ *
218
+ * @private
219
+ * @param {string} pattern - Route pattern with :param placeholders
220
+ * @param {string} path - Actual path to match
221
+ * @returns {Object|null} Match result with params, or null if no match
222
+ */
223
+ _matchDynamicRoute(pattern, path) {
224
+ // Extract param names and build regex
225
+ const paramNames = []
226
+ const regexStr = pattern
227
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars except :
228
+ .replace(/:(\w+)/g, (_, paramName) => {
229
+ paramNames.push(paramName)
230
+ return '([^/]+)' // Capture anything except /
231
+ })
232
+
233
+ const regex = new RegExp(`^${regexStr}$`)
234
+ const match = path.match(regex)
235
+
236
+ if (!match) return null
237
+
238
+ // Build params object
239
+ const params = {}
240
+ paramNames.forEach((name, i) => {
241
+ params[name] = decodeURIComponent(match[i + 1])
242
+ })
243
+
244
+ return { params }
245
+ }
246
+
247
+ /**
248
+ * Create a dynamic page instance with concrete route and params
249
+ *
250
+ * @private
251
+ * @param {Page} templatePage - The template page with :param route
252
+ * @param {string} concreteRoute - The actual route (e.g., /blog/my-post)
253
+ * @param {Object} params - Matched params (e.g., { slug: 'my-post' })
254
+ * @returns {Page|null} New page instance or null
255
+ */
256
+ _createDynamicPage(templatePage, concreteRoute, params) {
257
+ // Get the original page data
258
+ const originalData = this._dynamicPageData.get(templatePage.route)
259
+ if (!originalData) return null
260
+
261
+ // Deep clone the page data
262
+ const pageData = JSON.parse(JSON.stringify(originalData))
263
+
264
+ // Update with concrete route and dynamic context
265
+ pageData.route = concreteRoute
266
+ pageData.isDynamic = false // No longer a template
267
+
268
+ const paramName = Object.keys(params)[0]
269
+ const paramValue = Object.values(params)[0]
270
+ const pluralSchema = originalData.parentSchema // e.g., 'articles'
271
+ const singularSchema = this._singularize(pluralSchema) // e.g., 'article'
272
+
273
+ // Store dynamic context for components to access
274
+ pageData.dynamicContext = {
275
+ templateRoute: templatePage.route,
276
+ params,
277
+ paramName,
278
+ paramValue,
279
+ schema: pluralSchema,
280
+ singularSchema,
281
+ }
282
+
283
+ // Get the parent page's data to find the items array
284
+ // Parent route is the template route without the :param suffix
285
+ const parentRoute = templatePage.route.replace(/\/:[\w]+$/, '') || '/'
286
+ const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
287
+
288
+ // Get items from parent's cascaded data
289
+ let items = []
290
+ let currentItem = null
291
+
292
+ if (parentPage && pluralSchema) {
293
+ // Get items from parent page's first section's cascadedData
294
+ // This is where the page-level fetch stores its data
295
+ const firstSection = parentPage.pageBlocks?.body?.[0]
296
+ if (firstSection) {
297
+ items = firstSection.cascadedData?.[pluralSchema] || []
298
+ }
299
+
300
+ // Find the current item using the param
301
+ if (items.length > 0) {
302
+ currentItem = items.find(item => String(item[paramName]) === String(paramValue))
303
+ }
304
+ }
305
+
306
+ // Store items in dynamic context for Block.getCurrentItem() / getAllItems()
307
+ pageData.dynamicContext.currentItem = currentItem
308
+ pageData.dynamicContext.allItems = items
309
+
310
+ // Inject cascaded data into sections for components with inheritData
311
+ // This provides both singular (article) and plural (articles) data
312
+ const cascadedData = {}
313
+ if (currentItem && singularSchema) {
314
+ cascadedData[singularSchema] = currentItem
315
+ }
316
+ if (items.length > 0 && pluralSchema) {
317
+ cascadedData[pluralSchema] = items
318
+ }
319
+
320
+ this._injectDynamicData(pageData.sections, cascadedData, pageData.dynamicContext)
321
+
322
+ // Update page metadata from current item if available
323
+ if (currentItem) {
324
+ if (currentItem.title) pageData.title = currentItem.title
325
+ if (currentItem.description || currentItem.excerpt) {
326
+ pageData.description = currentItem.description || currentItem.excerpt
327
+ }
328
+ }
329
+
330
+ // Create the page instance
331
+ const dynamicPage = new Page(
332
+ pageData,
333
+ `dynamic-${concreteRoute}`,
334
+ this,
335
+ this.headerPage,
336
+ this.footerPage,
337
+ this.leftPage,
338
+ this.rightPage
339
+ )
340
+
341
+ // Copy parent reference from template
342
+ dynamicPage.parent = templatePage.parent
343
+
344
+ return dynamicPage
345
+ }
346
+
347
+ /**
348
+ * Singularize a plural schema name
349
+ * @private
350
+ */
351
+ _singularize(name) {
352
+ if (!name) return name
353
+ // Common irregular plurals
354
+ const irregulars = {
355
+ people: 'person',
356
+ children: 'child',
357
+ men: 'men',
358
+ women: 'woman',
359
+ series: 'series',
360
+ }
361
+ if (irregulars[name]) return irregulars[name]
362
+ // -ies → -y (categories → category)
363
+ if (name.endsWith('ies')) return name.slice(0, -3) + 'y'
364
+ // -es endings that should only remove 's' (not 'es')
365
+ // e.g., articles → article, courses → course
366
+ if (name.endsWith('es')) {
367
+ // Check if the base word ends in a consonant that requires 'es' plural
368
+ // (boxes, dishes, classes, heroes) vs just 's' plural (articles, courses)
369
+ const base = name.slice(0, -2)
370
+ const lastChar = base.slice(-1)
371
+ // If base ends in s, x, z, ch, sh - these need 'es' for plural, so remove 'es'
372
+ if (['s', 'x', 'z'].includes(lastChar) || base.endsWith('ch') || base.endsWith('sh')) {
373
+ return base
374
+ }
375
+ // Otherwise just remove 's' (articles → article)
376
+ return name.slice(0, -1)
377
+ }
378
+ // Regular -s plurals
379
+ if (name.endsWith('s')) return name.slice(0, -1)
380
+ return name
381
+ }
382
+
383
+ /**
384
+ * Inject dynamic route data into sections for components with inheritData
385
+ * This provides both the current item (singular) and all items (plural)
386
+ *
387
+ * @private
388
+ * @param {Array} sections - Sections to update
389
+ * @param {Object} cascadedData - Data to inject { article: {...}, articles: [...] }
390
+ * @param {Object} dynamicContext - Dynamic route context
391
+ */
392
+ _injectDynamicData(sections, cascadedData, dynamicContext) {
393
+ if (!sections || !Array.isArray(sections)) return
394
+
395
+ for (const section of sections) {
396
+ // Merge cascaded data into section's existing cascadedData
397
+ section.cascadedData = {
398
+ ...(section.cascadedData || {}),
399
+ ...cascadedData,
400
+ }
401
+
402
+ // Also set dynamic context for Block.getDynamicContext()
403
+ section.dynamicContext = dynamicContext
404
+
405
+ // Recurse into subsections
406
+ if (section.subsections && section.subsections.length > 0) {
407
+ this._injectDynamicData(section.subsections, cascadedData, dynamicContext)
408
+ }
409
+ }
171
410
  }
172
411
 
173
412
  /**
@@ -560,4 +799,64 @@ export default class Website {
560
799
  getActiveRootSegment() {
561
800
  return this.getActiveRoute().split('/')[0]
562
801
  }
802
+
803
+ /**
804
+ * Normalize a route by removing leading/trailing slashes.
805
+ * This is the single source of truth for route normalization.
806
+ *
807
+ * @param {string} route - Route to normalize
808
+ * @returns {string} Normalized route (e.g., 'docs/getting-started')
809
+ *
810
+ * @example
811
+ * website.normalizeRoute('/docs/guide/') // 'docs/guide'
812
+ * website.normalizeRoute('about') // 'about'
813
+ * website.normalizeRoute('/') // ''
814
+ */
815
+ normalizeRoute(route) {
816
+ return (route || '').replace(/^\/+/, '').replace(/\/+$/, '')
817
+ }
818
+
819
+ /**
820
+ * Check if a target route matches the current route exactly.
821
+ *
822
+ * @param {string} targetRoute - Route to check (will be normalized)
823
+ * @param {string} currentRoute - Current route (will be normalized)
824
+ * @returns {boolean} True if routes match exactly
825
+ *
826
+ * @example
827
+ * website.isRouteActive('/about', '/about') // true
828
+ * website.isRouteActive('/about', '/about/team') // false
829
+ */
830
+ isRouteActive(targetRoute, currentRoute) {
831
+ return this.normalizeRoute(targetRoute) === this.normalizeRoute(currentRoute)
832
+ }
833
+
834
+ /**
835
+ * Check if a target route matches the current route or is an ancestor of it.
836
+ * Used for navigation highlighting where parent items should be highlighted
837
+ * when a child page is active.
838
+ *
839
+ * @param {string} targetRoute - Route to check (will be normalized)
840
+ * @param {string} currentRoute - Current route (will be normalized)
841
+ * @returns {boolean} True if target matches current or is an ancestor
842
+ *
843
+ * @example
844
+ * website.isRouteActiveOrAncestor('/docs', '/docs') // true (exact)
845
+ * website.isRouteActiveOrAncestor('/docs', '/docs/guide') // true (ancestor)
846
+ * website.isRouteActiveOrAncestor('/about', '/docs/guide') // false
847
+ * website.isRouteActiveOrAncestor('/', '/docs') // false (root is not ancestor of all)
848
+ */
849
+ isRouteActiveOrAncestor(targetRoute, currentRoute) {
850
+ const target = this.normalizeRoute(targetRoute)
851
+ const current = this.normalizeRoute(currentRoute)
852
+
853
+ // Exact match
854
+ if (target === current) return true
855
+
856
+ // Empty target (root) is not considered ancestor of everything
857
+ if (target === '') return false
858
+
859
+ // Check if current starts with target followed by /
860
+ return current.startsWith(target + '/')
861
+ }
563
862
  }