@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 +1 -1
- package/src/block.js +78 -0
- package/src/page.js +62 -21
- package/src/website.js +310 -11
package/package.json
CHANGED
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(
|
|
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
|
-
*
|
|
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
|
|
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}
|
|
481
|
+
* @param {string} currentRoute - Current route to compare against
|
|
437
482
|
* @returns {boolean} True if this page's route matches
|
|
438
483
|
*/
|
|
439
|
-
isActiveFor(
|
|
440
|
-
return this.
|
|
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}
|
|
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(
|
|
456
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
161
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
170
|
-
|
|
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
|
}
|