@uniweb/core 0.1.9 → 0.1.11

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 CHANGED
@@ -126,7 +126,6 @@ page.label // Short navigation label (or null)
126
126
  page.order // Sort order
127
127
  page.children // Child pages (for nested hierarchy)
128
128
  page.website // Back-reference to parent Website
129
- page.site // Alias for page.website
130
129
 
131
130
  // Navigation visibility
132
131
  page.hidden // Hidden from all navigation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,6 +27,6 @@
27
27
  "node": ">=20.19"
28
28
  },
29
29
  "dependencies": {
30
- "@uniweb/semantic-parser": "1.0.7"
30
+ "@uniweb/semantic-parser": "1.0.8"
31
31
  }
32
32
  }
package/src/block.js CHANGED
@@ -175,17 +175,18 @@ export default class Block {
175
175
  return null
176
176
  }
177
177
 
178
- // Get component-level block configuration
179
- // Supports: Component.block (preferred), Component.blockDefaults (legacy)
180
- const blockConfig = this.Component.block || this.Component.blockDefaults || {}
178
+ // Get runtime metadata for this component (from meta.js, extracted at build time)
179
+ const meta = globalThis.uniweb?.getComponentMeta(this.type) || {}
181
180
 
182
181
  // Initialize state (dynamic, can change at runtime)
183
- const stateDefaults = blockConfig.state || this.Component.blockState
182
+ // Source: meta.js initialState field
183
+ const stateDefaults = meta.initialState
184
184
  this.startState = stateDefaults ? { ...stateDefaults } : null
185
185
  this.initState()
186
186
 
187
187
  // Initialize context (static, per component type)
188
- this.context = blockConfig.context ? { ...blockConfig.context } : null
188
+ // Source: meta.js context field
189
+ this.context = meta.context ? { ...meta.context } : null
189
190
 
190
191
  return this.Component
191
192
  }
package/src/page.js CHANGED
@@ -11,6 +11,7 @@ export default class Page {
11
11
  constructor(pageData, id, website, pageHeader, pageFooter, pageLeft, pageRight) {
12
12
  this.id = id
13
13
  this.route = pageData.route
14
+ this.isIndex = pageData.isIndex || false // True if this page is the index for its parent route
14
15
  this.title = pageData.title || ''
15
16
  this.description = pageData.description || ''
16
17
  this.label = pageData.label || null // Short label for navigation (null = use title)
@@ -50,7 +51,6 @@ export default class Page {
50
51
 
51
52
  // Back-reference to website
52
53
  this.website = website
53
- this.site = website // Alias
54
54
 
55
55
  // Scroll position memory (for navigation restoration)
56
56
  this.scrollY = 0
@@ -293,6 +293,26 @@ export default class Page {
293
293
  // Navigation and Layout Helpers
294
294
  // ─────────────────────────────────────────────────────────────────
295
295
 
296
+ /**
297
+ * Get the navigation route (canonical route for links)
298
+ * For index pages, returns the parent route (e.g., '/' for homepage)
299
+ * For regular pages, returns the actual route
300
+ * @returns {string}
301
+ */
302
+ getNavRoute() {
303
+ if (!this.isIndex) {
304
+ return this.route
305
+ }
306
+ // Index page - compute parent route
307
+ // /home -> /
308
+ // /docs/getting-started -> /docs
309
+ const segments = this.route.split('/').filter(Boolean)
310
+ if (segments.length <= 1) {
311
+ return '/'
312
+ }
313
+ return '/' + segments.slice(0, -1).join('/')
314
+ }
315
+
296
316
  /**
297
317
  * Get display label for navigation (short form of title)
298
318
  * @returns {string}
@@ -364,4 +384,80 @@ export default class Page {
364
384
  hasChildren() {
365
385
  return this.children.length > 0
366
386
  }
387
+
388
+ /**
389
+ * Check if page has body content (sections)
390
+ * @returns {boolean}
391
+ */
392
+ hasContent() {
393
+ return this.pageBlocks.body.length > 0
394
+ }
395
+
396
+ // ─────────────────────────────────────────────────────────────────
397
+ // Active Route Detection
398
+ // ─────────────────────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Get the first navigable route for this page.
402
+ * If page has no content, recursively finds first child with content.
403
+ * Useful for category pages that are just navigation containers.
404
+ *
405
+ * @returns {string} The route to navigate to
406
+ *
407
+ * @example
408
+ * // For a "Docs" category page with no content but children:
409
+ * // page.route = '/docs'
410
+ * // page.hasContent() = false
411
+ * // First child with content: '/docs/getting-started'
412
+ * page.getNavigableRoute() // Returns '/docs/getting-started'
413
+ */
414
+ getNavigableRoute() {
415
+ if (this.hasContent()) return this.route
416
+ for (const child of this.children || []) {
417
+ const route = child.getNavigableRoute()
418
+ if (route) return route
419
+ }
420
+ return this.route // Fallback to own route
421
+ }
422
+
423
+ /**
424
+ * Get route without leading/trailing slashes.
425
+ * Useful for route comparisons.
426
+ *
427
+ * @returns {string} Normalized route (e.g., 'docs/getting-started')
428
+ */
429
+ getNormalizedRoute() {
430
+ return (this.route || '').replace(/^\//, '').replace(/\/$/, '')
431
+ }
432
+
433
+ /**
434
+ * Check if this page matches the given route exactly.
435
+ *
436
+ * @param {string} route - Normalized route (no leading/trailing slashes)
437
+ * @returns {boolean} True if this page's route matches
438
+ */
439
+ isActiveFor(route) {
440
+ return this.getNormalizedRoute() === route
441
+ }
442
+
443
+ /**
444
+ * Check if this page or any descendant matches the given route.
445
+ * Useful for highlighting parent nav items when a child page is active.
446
+ *
447
+ * @param {string} route - Normalized route (no leading/trailing slashes)
448
+ * @returns {boolean} True if this page or a descendant is active
449
+ *
450
+ * @example
451
+ * // Page route: '/docs'
452
+ * // Current route: 'docs/getting-started/installation'
453
+ * page.isActiveOrAncestor('docs/getting-started/installation') // true
454
+ */
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 + '/')
462
+ }
367
463
  }
package/src/uniweb.js CHANGED
@@ -14,7 +14,8 @@ export default class Uniweb {
14
14
  this.childBlockRenderer = null // Function to render child blocks
15
15
  this.routingComponents = {} // Link, SafeHtml, useNavigate, etc.
16
16
  this.foundation = null // The loaded foundation module
17
- this.foundationConfig = {} // Configuration from foundation
17
+ this.foundationConfig = {} // Configuration from foundation (capabilities)
18
+ this.meta = {} // Per-component runtime metadata (from meta.js)
18
19
  this.language = 'en'
19
20
 
20
21
  // Initialize analytics (disabled by default, configure via site config)
@@ -27,6 +28,29 @@ export default class Uniweb {
27
28
  */
28
29
  setFoundation(foundation) {
29
30
  this.foundation = foundation
31
+
32
+ // Store per-component metadata if present
33
+ if (foundation.meta) {
34
+ this.meta = foundation.meta
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Get runtime metadata for a component
40
+ * @param {string} componentName
41
+ * @returns {Object|null} Meta with defaults, context, initialState, background, data
42
+ */
43
+ getComponentMeta(componentName) {
44
+ return this.meta[componentName] || null
45
+ }
46
+
47
+ /**
48
+ * Get default param values for a component
49
+ * @param {string} componentName
50
+ * @returns {Object} Default values (empty object if none)
51
+ */
52
+ getComponentDefaults(componentName) {
53
+ return this.meta[componentName]?.defaults || {}
30
54
  }
31
55
 
32
56
  /**
package/src/website.js CHANGED
@@ -52,8 +52,9 @@ export default class Website {
52
52
  // Build parent-child relationships based on route structure
53
53
  this.buildPageHierarchy()
54
54
 
55
+ // Find the homepage (root-level index page)
55
56
  this.activePage =
56
- this.pages.find((page) => page.route === '/' || page.route === '/index') ||
57
+ this.pages.find((page) => page.isIndex && page.getNavRoute() === '/') ||
57
58
  this.pages[0]
58
59
 
59
60
  this.pageRoutes = this.pages.map((page) => page.route)
@@ -115,22 +116,28 @@ export default class Website {
115
116
  })
116
117
 
117
118
  // Build a map of route to page for quick lookup
119
+ // Include both actual routes and nav routes (for index pages)
118
120
  const pageMap = new Map()
119
121
  for (const page of sortedPages) {
120
122
  pageMap.set(page.route, page)
123
+ // Also map the nav route for index pages so parent lookup works
124
+ if (page.isIndex) {
125
+ const navRoute = page.getNavRoute()
126
+ if (navRoute !== page.route) {
127
+ pageMap.set(navRoute, page)
128
+ }
129
+ }
121
130
  }
122
131
 
123
132
  // For each page, find its parent and add it as a child
124
133
  for (const page of sortedPages) {
125
134
  const route = page.route
126
- if (route === '/' || route === '') continue
127
-
128
- // Find parent route by removing the last segment
129
- // /getting-started/installation -> /getting-started
135
+ // Skip root-level pages (single segment like /home, /about)
130
136
  const segments = route.split('/').filter(Boolean)
131
- if (segments.length <= 1) continue // Root-level pages have no parent
137
+ if (segments.length <= 1) continue
132
138
 
133
- // Build parent route
139
+ // Build parent route by removing the last segment
140
+ // /docs/getting-started -> /docs
134
141
  const parentRoute = '/' + segments.slice(0, -1).join('/')
135
142
  const parent = pageMap.get(parentRoute)
136
143
 
@@ -150,11 +157,17 @@ export default class Website {
150
157
 
151
158
  /**
152
159
  * Get page by route
160
+ * Matches both actual routes and nav routes (for index pages)
153
161
  * @param {string} route
154
162
  * @returns {Page|undefined}
155
163
  */
156
164
  getPage(route) {
157
- return this.pages.find((page) => page.route === route)
165
+ // First try exact match on actual route
166
+ const exactMatch = this.pages.find((page) => page.route === route)
167
+ if (exactMatch) return exactMatch
168
+
169
+ // Then try matching nav route (for index pages accessible at parent route)
170
+ return this.pages.find((page) => page.isIndex && page.getNavRoute() === route)
158
171
  }
159
172
 
160
173
  /**
@@ -475,12 +488,13 @@ export default class Website {
475
488
  // Build page info objects
476
489
  const buildPageInfo = (page) => ({
477
490
  id: page.id,
478
- route: page.route,
491
+ route: page.getNavRoute(), // Use canonical nav route (e.g., '/' for index pages)
492
+ navigableRoute: page.getNavigableRoute(), // First route with content (for links)
479
493
  title: page.title,
480
494
  label: page.getLabel(),
481
495
  description: page.description,
482
496
  order: page.order,
483
- hasContent: page.getBodyBlocks().length > 0,
497
+ hasContent: page.hasContent(),
484
498
  children: nested && page.hasChildren()
485
499
  ? page.children.filter(isPageVisible).map(buildPageInfo)
486
500
  : []
@@ -515,4 +529,35 @@ export default class Website {
515
529
  getAllPages(includeHidden = false) {
516
530
  return this.getPageHierarchy({ nested: false, includeHidden })
517
531
  }
532
+
533
+ // ─────────────────────────────────────────────────────────────────
534
+ // Active Route API (for navigation components)
535
+ // ─────────────────────────────────────────────────────────────────
536
+
537
+ /**
538
+ * Get the current active route, normalized (no leading/trailing slashes).
539
+ * Works in both SSR (from activePage) and client (from activePage).
540
+ *
541
+ * @returns {string} Normalized route (e.g., 'docs/getting-started')
542
+ *
543
+ * @example
544
+ * website.getActiveRoute() // 'docs/getting-started'
545
+ */
546
+ getActiveRoute() {
547
+ return this.activePage?.getNormalizedRoute() || ''
548
+ }
549
+
550
+ /**
551
+ * Get the first segment of the active route.
552
+ * Useful for root-level navigation highlighting.
553
+ *
554
+ * @returns {string} First segment (e.g., 'docs' for 'docs/getting-started')
555
+ *
556
+ * @example
557
+ * // Active route: 'docs/getting-started/installation'
558
+ * website.getActiveRootSegment() // 'docs'
559
+ */
560
+ getActiveRootSegment() {
561
+ return this.getActiveRoute().split('/')[0]
562
+ }
518
563
  }