@uniweb/core 0.1.4 → 0.1.6

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
@@ -56,22 +56,130 @@ uniweb.setFoundation(module) // Set the foundation module
56
56
  Manages pages, theme, and localization.
57
57
 
58
58
  ```js
59
+ // Page navigation
59
60
  website.getPage(route) // Get page by route
60
61
  website.setActivePage(route) // Navigate to page
61
- website.localize(value) // Localize a multilingual value
62
- website.getLanguage() // Get current language code
63
- website.getLanguages() // Get available languages
62
+ website.activePage // Current active page
63
+ website.pages // All pages
64
+ website.pageRoutes // Array of route strings
65
+
66
+ // Page Hierarchy API (for navbars, footers, sitemaps)
67
+ website.getPageHierarchy(options) // Get pages for navigation
68
+ website.getHeaderPages() // Convenience: pages for header nav
69
+ website.getFooterPages() // Convenience: pages for footer nav
70
+ website.getAllPages() // Get flat list of all pages
71
+
72
+ // Locale API
73
+ website.getLocales() // Get all locales: [{code, label, isDefault}]
74
+ website.getActiveLocale() // Get current locale code
75
+ website.getDefaultLocale() // Get default locale code
76
+ website.hasMultipleLocales() // Check if site has multiple locales
77
+ website.getLocaleUrl(code, route) // Build URL for a locale
78
+ website.setActiveLocale(code) // Set active locale
79
+ website.getLocale(code) // Get locale info by code
80
+
81
+ // Content localization (for multilingual values)
82
+ website.localize(value) // Localize {en: "Hello", es: "Hola"} to active lang
64
83
  website.makeHref(href) // Transform href for routing
84
+
85
+ // Deprecated (use Locale API instead)
86
+ website.getLanguage() // Use getActiveLocale()
87
+ website.getLanguages() // Use getLocales()
65
88
  ```
66
89
 
90
+ **Page Hierarchy API**
91
+
92
+ The `getPageHierarchy()` method returns pages filtered and formatted for navigation:
93
+
94
+ ```js
95
+ // Get pages for header navigation
96
+ const headerPages = website.getPageHierarchy({ for: 'header' })
97
+ // Returns: [{ id, route, title, label, description, order, hasContent, children }]
98
+
99
+ // Get flat list of all pages (for sitemaps)
100
+ const allPages = website.getPageHierarchy({ nested: false, includeHidden: true })
101
+
102
+ // Custom filtering and sorting
103
+ const topLevel = website.getPageHierarchy({
104
+ filter: (page) => page.order < 10,
105
+ sort: (a, b) => a.title.localeCompare(b.title)
106
+ })
107
+ ```
108
+
109
+ Options:
110
+ - `nested` (default: true) - Return with nested children or flat list
111
+ - `for` - Filter for 'header', 'footer', or undefined (all)
112
+ - `includeHidden` (default: false) - Include hidden pages
113
+ - `filter` - Custom filter function: `(page) => boolean`
114
+ - `sort` - Custom sort function: `(a, b) => number`
115
+
67
116
  #### Page
68
117
 
69
118
  Represents a page with its sections.
70
119
 
71
120
  ```js
121
+ // Basic properties
72
122
  page.route // Page route path
73
123
  page.title // Page title
74
- page.sections // Array of section blocks
124
+ page.description // Page description
125
+ page.label // Short navigation label (or null)
126
+ page.order // Sort order
127
+ page.children // Child pages (for nested hierarchy)
128
+ page.website // Back-reference to parent Website
129
+ page.site // Alias for page.website
130
+
131
+ // Navigation visibility
132
+ page.hidden // Hidden from all navigation
133
+ page.hideInHeader // Hidden from header nav only
134
+ page.hideInFooter // Hidden from footer nav only
135
+ page.isHidden() // Check if hidden from navigation
136
+ page.showInHeader() // Should appear in header nav?
137
+ page.showInFooter() // Should appear in footer nav?
138
+ page.getLabel() // Get navigation label (falls back to title)
139
+
140
+ // Layout options (per-page overrides)
141
+ page.layout.header // Show header on this page?
142
+ page.layout.footer // Show footer on this page?
143
+ page.layout.leftPanel // Show left panel?
144
+ page.layout.rightPanel // Show right panel?
145
+ page.hasHeader() // Convenience: page.layout.header
146
+ page.hasFooter() // Convenience: page.layout.footer
147
+ page.hasLeftPanel() // Convenience: page.layout.leftPanel
148
+ page.hasRightPanel() // Convenience: page.layout.rightPanel
149
+
150
+ // Content
151
+ page.getPageBlocks() // Get header + body + footer blocks
152
+ page.getBodyBlocks() // Get just body blocks
153
+ page.getHeader() // Get header block
154
+ page.getFooter() // Get footer block
155
+ page.hasChildren() // Has child pages?
156
+ page.getHeadMeta() // Get SEO meta tags
157
+ ```
158
+
159
+ **Page Configuration (page.yml)**
160
+
161
+ ```yaml
162
+ title: About Us
163
+ description: Learn about our company
164
+ label: About # Short nav label (optional)
165
+ order: 2
166
+
167
+ # Navigation visibility
168
+ hidden: true # Hide from all navigation
169
+ hideInHeader: true # Hide from header nav only
170
+ hideInFooter: true # Hide from footer nav only
171
+
172
+ # Layout overrides (default: all true)
173
+ layout:
174
+ header: false # Don't show header on this page
175
+ footer: false # Don't show footer on this page
176
+ leftPanel: false # Don't show left panel
177
+ rightPanel: false # Don't show right panel
178
+
179
+ # SEO (optional)
180
+ seo:
181
+ noindex: false
182
+ image: /about-og.png
75
183
  ```
76
184
 
77
185
  #### Block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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.3"
30
+ "@uniweb/semantic-parser": "1.0.7"
31
31
  }
32
32
  }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Analytics
3
+ *
4
+ * Lightweight analytics class for tracking page views, events, and scroll depth.
5
+ * Uses batched sending with sendBeacon for reliable delivery.
6
+ *
7
+ * Features:
8
+ * - Batched event queue with periodic flush
9
+ * - Page view tracking
10
+ * - Custom event tracking
11
+ * - Scroll depth tracking (25%, 50%, 75%, 100%)
12
+ * - sendBeacon for reliable unload delivery
13
+ * - Optional - silently ignores if not configured
14
+ *
15
+ * Usage via uniweb singleton:
16
+ * ```js
17
+ * // Track events (no-op if analytics not configured)
18
+ * uniweb.analytics.trackPageView('/about', 'About Us')
19
+ * uniweb.analytics.trackEvent('button_click', { buttonId: 'cta' })
20
+ * uniweb.analytics.trackScrollDepth(50)
21
+ * ```
22
+ */
23
+
24
+ // Check if running in browser environment
25
+ const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
26
+
27
+ export default class Analytics {
28
+ /**
29
+ * @param {Object} options
30
+ * @param {string} options.endpoint - Analytics endpoint URL (required to enable)
31
+ * @param {number} options.flushInterval - Interval to flush queue in ms (default: 5000)
32
+ * @param {number} options.maxQueueSize - Max events before auto-flush (default: 10)
33
+ * @param {boolean} options.debug - Enable debug logging (default: false)
34
+ */
35
+ constructor(options = {}) {
36
+ this.endpoint = options.endpoint || null
37
+ this.flushInterval = options.flushInterval || 5000
38
+ this.maxQueueSize = options.maxQueueSize || 10
39
+ this.debug = options.debug || false
40
+
41
+ // Event queue
42
+ this.queue = []
43
+
44
+ // Track scroll depth milestones already sent (to avoid duplicates)
45
+ this.scrollMilestones = new Set()
46
+
47
+ // Session info
48
+ this.sessionId = this.generateSessionId()
49
+ this.sessionStart = Date.now()
50
+
51
+ // Only set up browser handlers if in browser and configured
52
+ if (isBrowser && this.isEnabled()) {
53
+ this.setupFlushInterval()
54
+ this.setupUnloadHandler()
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if analytics is enabled
60
+ * @returns {boolean}
61
+ */
62
+ isEnabled() {
63
+ return !!this.endpoint
64
+ }
65
+
66
+ /**
67
+ * Generate a simple session ID
68
+ * @returns {string}
69
+ */
70
+ generateSessionId() {
71
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
72
+ }
73
+
74
+ /**
75
+ * Set up periodic flush interval
76
+ */
77
+ setupFlushInterval() {
78
+ this.flushIntervalId = setInterval(() => {
79
+ this.flush()
80
+ }, this.flushInterval)
81
+ }
82
+
83
+ /**
84
+ * Set up unload handler for final flush
85
+ */
86
+ setupUnloadHandler() {
87
+ const handleUnload = () => {
88
+ this.flush(true) // Force beacon
89
+ }
90
+
91
+ window.addEventListener('visibilitychange', () => {
92
+ if (document.visibilityState === 'hidden') {
93
+ handleUnload()
94
+ }
95
+ })
96
+
97
+ window.addEventListener('pagehide', handleUnload)
98
+ }
99
+
100
+ /**
101
+ * Add event to queue
102
+ * @param {string} type - Event type
103
+ * @param {Object} data - Event data
104
+ */
105
+ addToQueue(type, data) {
106
+ if (!this.isEnabled() || !isBrowser) return
107
+
108
+ const event = {
109
+ type,
110
+ data,
111
+ timestamp: Date.now(),
112
+ sessionId: this.sessionId,
113
+ url: window.location.href,
114
+ referrer: document.referrer || null
115
+ }
116
+
117
+ this.queue.push(event)
118
+
119
+ if (this.debug) {
120
+ console.log('[Analytics] Event queued:', event)
121
+ }
122
+
123
+ // Auto-flush if queue is full
124
+ if (this.queue.length >= this.maxQueueSize) {
125
+ this.flush()
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Track a page view
131
+ * @param {string} path - Page path
132
+ * @param {string} title - Page title
133
+ * @param {Object} meta - Additional metadata
134
+ */
135
+ trackPageView(path, title, meta = {}) {
136
+ // Reset scroll milestones for new page
137
+ this.scrollMilestones.clear()
138
+
139
+ this.addToQueue('pageview', {
140
+ path,
141
+ title,
142
+ ...meta
143
+ })
144
+ }
145
+
146
+ /**
147
+ * Track a custom event
148
+ * @param {string} name - Event name
149
+ * @param {Object} data - Event data
150
+ */
151
+ trackEvent(name, data = {}) {
152
+ this.addToQueue('event', {
153
+ name,
154
+ ...data
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Track scroll depth milestone
160
+ * @param {number} percentage - Scroll depth percentage (25, 50, 75, 100)
161
+ */
162
+ trackScrollDepth(percentage) {
163
+ // Only track standard milestones
164
+ const milestones = [25, 50, 75, 100]
165
+ if (!milestones.includes(percentage)) return
166
+
167
+ // Don't track the same milestone twice per page
168
+ if (this.scrollMilestones.has(percentage)) return
169
+
170
+ this.scrollMilestones.add(percentage)
171
+
172
+ this.addToQueue('scroll_depth', {
173
+ depth: percentage
174
+ })
175
+ }
176
+
177
+ /**
178
+ * Flush the event queue
179
+ * @param {boolean} useBeacon - Force use of sendBeacon (for unload)
180
+ */
181
+ flush(useBeacon = false) {
182
+ if (!this.isEnabled() || !isBrowser || this.queue.length === 0) return
183
+
184
+ const events = [...this.queue]
185
+ this.queue = []
186
+
187
+ const payload = JSON.stringify({
188
+ events,
189
+ sessionId: this.sessionId,
190
+ sessionDuration: Date.now() - this.sessionStart
191
+ })
192
+
193
+ if (this.debug) {
194
+ console.log('[Analytics] Flushing', events.length, 'events')
195
+ }
196
+
197
+ // Use sendBeacon for reliable delivery on page unload
198
+ if (useBeacon && navigator.sendBeacon) {
199
+ const blob = new Blob([payload], { type: 'application/json' })
200
+ const sent = navigator.sendBeacon(this.endpoint, blob)
201
+
202
+ if (!sent && this.debug) {
203
+ console.warn('[Analytics] sendBeacon failed, events may be lost')
204
+ }
205
+ return
206
+ }
207
+
208
+ // Use fetch for normal flush
209
+ fetch(this.endpoint, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: payload,
213
+ keepalive: true // Allows request to outlive page
214
+ }).catch((error) => {
215
+ if (this.debug) {
216
+ console.warn('[Analytics] Flush failed:', error)
217
+ }
218
+ // Put events back in queue for retry
219
+ this.queue.unshift(...events)
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Clean up (stop interval, flush remaining events)
225
+ */
226
+ destroy() {
227
+ if (this.flushIntervalId) {
228
+ clearInterval(this.flushIntervalId)
229
+ }
230
+ this.flush(true)
231
+ }
232
+ }
package/src/block.js CHANGED
@@ -10,9 +10,14 @@ import { parseContent as parseSemanticContent } from '@uniweb/semantic-parser'
10
10
  export default class Block {
11
11
  constructor(blockData, id) {
12
12
  this.id = id
13
- this.component = blockData.component || 'Section'
13
+ // 'type' matches frontmatter convention; 'component' supported for backwards compatibility
14
+ this.type = blockData.type || blockData.component || 'Section'
14
15
  this.Component = null
15
16
 
17
+ // Back-references (set by Page when creating blocks)
18
+ this.page = null
19
+ this.website = null
20
+
16
21
  // Content structure
17
22
  // The content can be:
18
23
  // 1. Raw ProseMirror content (from content collection)
@@ -28,7 +33,7 @@ export default class Block {
28
33
  // Block configuration
29
34
  const blockConfig = blockData.params || blockData.config || {}
30
35
  this.preset = blockData.preset
31
- this.themeName = `context__${blockConfig.theme || 'light'}`
36
+ this.themeName = blockConfig.theme || 'light'
32
37
  this.standardOptions = blockConfig.standardOptions || {}
33
38
  this.properties = blockConfig.properties || blockConfig
34
39
 
@@ -40,10 +45,13 @@ export default class Block {
40
45
  // Input data
41
46
  this.input = blockData.input || null
42
47
 
43
- // State management
48
+ // State management (dynamic, can change at runtime)
44
49
  this.startState = null
45
50
  this.state = null
46
51
  this.resetStateHook = null
52
+
53
+ // Context (static, defined per component type)
54
+ this.context = null
47
55
  }
48
56
 
49
57
  /**
@@ -160,18 +168,25 @@ export default class Block {
160
168
  initComponent() {
161
169
  if (this.Component) return this.Component
162
170
 
163
- this.Component = globalThis.uniweb?.getComponent(this.component)
171
+ this.Component = globalThis.uniweb?.getComponent(this.type)
164
172
 
165
173
  if (!this.Component) {
166
- console.warn(`[Block] Component not found: ${this.component}`)
174
+ console.warn(`[Block] Component not found: ${this.type}`)
167
175
  return null
168
176
  }
169
177
 
170
- // Initialize state from component defaults
171
- const defaults = this.Component.blockDefaults || { state: this.Component.blockState }
172
- this.startState = defaults.state ? { ...defaults.state } : null
178
+ // Get component-level block configuration
179
+ // Supports: Component.block (preferred), Component.blockDefaults (legacy)
180
+ const blockConfig = this.Component.block || this.Component.blockDefaults || {}
181
+
182
+ // Initialize state (dynamic, can change at runtime)
183
+ const stateDefaults = blockConfig.state || this.Component.blockState
184
+ this.startState = stateDefaults ? { ...stateDefaults } : null
173
185
  this.initState()
174
186
 
187
+ // Initialize context (static, per component type)
188
+ this.context = blockConfig.context ? { ...blockConfig.context } : null
189
+
175
190
  return this.Component
176
191
  }
177
192
 
@@ -243,6 +258,59 @@ export default class Block {
243
258
  if (this.resetStateHook) this.resetStateHook()
244
259
  }
245
260
 
261
+ // ─────────────────────────────────────────────────────────────────
262
+ // Cross-Block Communication
263
+ // ─────────────────────────────────────────────────────────────────
264
+
265
+ /**
266
+ * Get this block's index within its page.
267
+ * Useful for finding neighboring blocks.
268
+ *
269
+ * @returns {number} The index, or -1 if not found
270
+ */
271
+ getIndex() {
272
+ if (!this.page) return -1
273
+ return this.page.getBlockIndex(this)
274
+ }
275
+
276
+ /**
277
+ * Get information about this block for cross-component communication.
278
+ * Other components (like NavBar) can use this to adapt their behavior.
279
+ *
280
+ * @returns {Object} Block info: { type, theme, state, context }
281
+ */
282
+ getBlockInfo() {
283
+ return {
284
+ type: this.type,
285
+ theme: this.themeName,
286
+ state: this.state,
287
+ context: this.context
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Get information about the next block in the page.
293
+ * Commonly used by headers/navbars to adapt to the first content section.
294
+ *
295
+ * @returns {Object|null} Next block's info or null
296
+ */
297
+ getNextBlockInfo() {
298
+ const index = this.getIndex()
299
+ if (index < 0 || !this.page) return null
300
+ return this.page.getBlockInfo(index + 1)
301
+ }
302
+
303
+ /**
304
+ * Get information about the previous block in the page.
305
+ *
306
+ * @returns {Object|null} Previous block's info or null
307
+ */
308
+ getPrevBlockInfo() {
309
+ const index = this.getIndex()
310
+ if (index <= 0 || !this.page) return null
311
+ return this.page.getBlockInfo(index - 1)
312
+ }
313
+
246
314
  /**
247
315
  * React hook for block state management
248
316
  * @param {Function} useState - React useState hook
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ export { default as Website } from './website.js'
13
13
  export { default as Page } from './page.js'
14
14
  export { default as Block } from './block.js'
15
15
  export { default as Input } from './input.js'
16
+ export { default as Analytics } from './analytics.js'
16
17
 
17
18
  /**
18
19
  * The singleton Uniweb instance.
package/src/page.js CHANGED
@@ -1,52 +1,211 @@
1
1
  /**
2
2
  * Page
3
3
  *
4
- * Represents a single page with header, body sections, and footer.
4
+ * Represents a single page with header, body, footer, and panel sections.
5
+ * Each layout area can have multiple sections/blocks.
5
6
  */
6
7
 
7
8
  import Block from './block.js'
8
9
 
9
10
  export default class Page {
10
- constructor(pageData, id, pageHeader, pageFooter) {
11
+ constructor(pageData, id, website, pageHeader, pageFooter, pageLeft, pageRight) {
11
12
  this.id = id
12
13
  this.route = pageData.route
13
14
  this.title = pageData.title || ''
14
15
  this.description = pageData.description || ''
16
+ this.label = pageData.label || null // Short label for navigation (null = use title)
17
+ this.keywords = pageData.keywords || null
18
+ this.order = pageData.order ?? 0
19
+ this.lastModified = pageData.lastModified || null
15
20
 
21
+ // Navigation visibility options
22
+ this.hidden = pageData.hidden || false
23
+ this.hideInHeader = pageData.hideInHeader || false
24
+ this.hideInFooter = pageData.hideInFooter || false
25
+
26
+ // Layout options (per-page overrides for header/footer/panels)
27
+ this.layout = {
28
+ header: pageData.layout?.header !== false,
29
+ footer: pageData.layout?.footer !== false,
30
+ left: pageData.layout?.left !== false,
31
+ right: pageData.layout?.right !== false,
32
+ // Aliases for backwards compatibility
33
+ leftPanel: pageData.layout?.left !== false,
34
+ rightPanel: pageData.layout?.right !== false
35
+ }
36
+
37
+ // SEO configuration
38
+ this.seo = {
39
+ noindex: pageData.seo?.noindex || false,
40
+ image: pageData.seo?.image || null,
41
+ ogTitle: pageData.seo?.ogTitle || null,
42
+ ogDescription: pageData.seo?.ogDescription || null,
43
+ canonical: pageData.seo?.canonical || null,
44
+ changefreq: pageData.seo?.changefreq || null,
45
+ priority: pageData.seo?.priority || null
46
+ }
47
+
48
+ // Child pages (for nested hierarchy) - populated by Website
49
+ this.children = []
50
+
51
+ // Back-reference to website
52
+ this.website = website
53
+ this.site = website // Alias
54
+
55
+ // Scroll position memory (for navigation restoration)
56
+ this.scrollY = 0
57
+
58
+ // Build block groups for all layout areas
16
59
  this.pageBlocks = this.buildPageBlocks(
17
60
  pageData.sections,
18
61
  pageHeader?.sections,
19
- pageFooter?.sections
62
+ pageFooter?.sections,
63
+ pageLeft?.sections,
64
+ pageRight?.sections
20
65
  )
21
66
  }
22
67
 
23
68
  /**
24
- * Build the page block structure
69
+ * Get metadata for head tags
70
+ * @returns {Object} Head metadata
71
+ */
72
+ getHeadMeta() {
73
+ return {
74
+ title: this.title,
75
+ description: this.description,
76
+ keywords: this.keywords,
77
+ canonical: this.seo.canonical,
78
+ robots: this.seo.noindex ? 'noindex, nofollow' : null,
79
+ og: {
80
+ title: this.seo.ogTitle || this.title,
81
+ description: this.seo.ogDescription || this.description,
82
+ image: this.seo.image,
83
+ url: this.route
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Build the page block structure for all layout areas.
90
+ * Each area can have multiple sections/blocks.
91
+ *
92
+ * @param {Array} body - Body sections from page content
93
+ * @param {Array} header - Header sections from @header page
94
+ * @param {Array} footer - Footer sections from @footer page
95
+ * @param {Array} left - Left panel sections from @left page
96
+ * @param {Array} right - Right panel sections from @right page
97
+ * @returns {Object} Block groups for each layout area
25
98
  */
26
- buildPageBlocks(body, header, footer) {
27
- const headerSection = header?.[0]
28
- const footerSection = footer?.[0]
29
- const bodySections = body || []
99
+ buildPageBlocks(body, header, footer, left, right) {
100
+ const buildBlocks = (sections, prefix) => {
101
+ if (!sections || sections.length === 0) return null
102
+ return sections.map((section, index) => {
103
+ const block = new Block(section, `${prefix}-${index}`)
104
+ this.initBlockReferences(block)
105
+ return block
106
+ })
107
+ }
108
+
109
+ const bodyBlocks = (body || []).map((section, index) => {
110
+ const block = new Block(section, index)
111
+ this.initBlockReferences(block)
112
+ return block
113
+ })
30
114
 
31
115
  return {
32
- header: headerSection ? new Block(headerSection, 'header') : null,
33
- body: bodySections.map((section, index) => new Block(section, index)),
34
- footer: footerSection ? new Block(footerSection, 'footer') : null,
35
- leftPanel: null,
36
- rightPanel: null
116
+ header: buildBlocks(header, 'header'),
117
+ body: bodyBlocks,
118
+ footer: buildBlocks(footer, 'footer'),
119
+ left: buildBlocks(left, 'left'),
120
+ right: buildBlocks(right, 'right')
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Initialize block back-references to page and website.
126
+ * Also recursively sets references for child blocks.
127
+ *
128
+ * @param {Block} block - The block to initialize
129
+ */
130
+ initBlockReferences(block) {
131
+ block.page = this
132
+ block.website = this.website
133
+
134
+ // Recursively set references for child blocks
135
+ if (block.childBlocks?.length) {
136
+ for (const childBlock of block.childBlocks) {
137
+ this.initBlockReferences(childBlock)
138
+ }
37
139
  }
38
140
  }
39
141
 
142
+ /**
143
+ * Get all block groups (for Layout component)
144
+ * @returns {Object} { header, body, footer, left, right }
145
+ */
146
+ getBlockGroups() {
147
+ return this.pageBlocks
148
+ }
149
+
150
+ // ─────────────────────────────────────────────────────────────────
151
+ // Cross-Block Communication
152
+ // ─────────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Find a block's index within the page's block list.
156
+ * Searches across all layout areas (header, body, footer, left, right).
157
+ *
158
+ * @param {Block} block - The block to find
159
+ * @returns {number} The index in the flat list, or -1 if not found
160
+ */
161
+ getBlockIndex(block) {
162
+ const allBlocks = this.getPageBlocks()
163
+ return allBlocks.indexOf(block)
164
+ }
165
+
166
+ /**
167
+ * Get information about a block at a specific index.
168
+ * Used for cross-component communication (e.g., NavBar checking Hero's theme).
169
+ *
170
+ * @param {number} index - The block index
171
+ * @returns {Object|null} Block info { theme, component, state } or null
172
+ */
173
+ getBlockInfo(index) {
174
+ const allBlocks = this.getPageBlocks()
175
+ const block = allBlocks[index]
176
+ return block?.getBlockInfo() || null
177
+ }
178
+
179
+ /**
180
+ * Get the first body block's info.
181
+ * Common use case: NavBar checking if first section supports overlay.
182
+ *
183
+ * @returns {Object|null} First body block's info or null
184
+ */
185
+ getFirstBodyBlockInfo() {
186
+ const bodyBlocks = this.pageBlocks.body
187
+ return bodyBlocks?.[0]?.getBlockInfo() || null
188
+ }
189
+
40
190
  /**
41
191
  * Get all blocks (header, body, footer) as flat array
192
+ * Respects page layout preferences (hasHeader, hasFooter, etc.)
42
193
  * @returns {Block[]}
43
194
  */
44
195
  getPageBlocks() {
45
- return [
46
- this.pageBlocks.header,
47
- ...this.pageBlocks.body,
48
- this.pageBlocks.footer
49
- ].filter(Boolean)
196
+ const blocks = []
197
+
198
+ if (this.hasHeader() && this.pageBlocks.header) {
199
+ blocks.push(...this.pageBlocks.header)
200
+ }
201
+
202
+ blocks.push(...this.pageBlocks.body)
203
+
204
+ if (this.hasFooter() && this.pageBlocks.footer) {
205
+ blocks.push(...this.pageBlocks.footer)
206
+ }
207
+
208
+ return blocks
50
209
  }
51
210
 
52
211
  /**
@@ -58,18 +217,151 @@ export default class Page {
58
217
  }
59
218
 
60
219
  /**
61
- * Get header block
220
+ * Get header blocks (respects layout preference)
221
+ * @returns {Block[]|null}
222
+ */
223
+ getHeaderBlocks() {
224
+ if (!this.hasHeader()) return null
225
+ return this.pageBlocks.header
226
+ }
227
+
228
+ /**
229
+ * Get footer blocks (respects layout preference)
230
+ * @returns {Block[]|null}
231
+ */
232
+ getFooterBlocks() {
233
+ if (!this.hasFooter()) return null
234
+ return this.pageBlocks.footer
235
+ }
236
+
237
+ /**
238
+ * Get left panel blocks (respects layout preference)
239
+ * @returns {Block[]|null}
240
+ */
241
+ getLeftBlocks() {
242
+ if (!this.hasLeftPanel()) return null
243
+ return this.pageBlocks.left
244
+ }
245
+
246
+ /**
247
+ * Get right panel blocks (respects layout preference)
248
+ * @returns {Block[]|null}
249
+ */
250
+ getRightBlocks() {
251
+ if (!this.hasRightPanel()) return null
252
+ return this.pageBlocks.right
253
+ }
254
+
255
+ /**
256
+ * Get header block (legacy - returns first block)
62
257
  * @returns {Block|null}
258
+ * @deprecated Use getHeaderBlocks() instead
63
259
  */
64
260
  getHeader() {
65
- return this.pageBlocks.header
261
+ return this.pageBlocks.header?.[0] || null
66
262
  }
67
263
 
68
264
  /**
69
- * Get footer block
265
+ * Get footer block (legacy - returns first block)
70
266
  * @returns {Block|null}
267
+ * @deprecated Use getFooterBlocks() instead
71
268
  */
72
269
  getFooter() {
73
- return this.pageBlocks.footer
270
+ return this.pageBlocks.footer?.[0] || null
271
+ }
272
+
273
+ /**
274
+ * Reset block states (for scroll restoration)
275
+ */
276
+ resetBlockStates() {
277
+ const allBlocks = [
278
+ ...(this.pageBlocks.header || []),
279
+ ...this.pageBlocks.body,
280
+ ...(this.pageBlocks.footer || []),
281
+ ...(this.pageBlocks.left || []),
282
+ ...(this.pageBlocks.right || [])
283
+ ]
284
+
285
+ for (const block of allBlocks) {
286
+ if (typeof block.initState === 'function') {
287
+ block.initState()
288
+ }
289
+ }
290
+ }
291
+
292
+ // ─────────────────────────────────────────────────────────────────
293
+ // Navigation and Layout Helpers
294
+ // ─────────────────────────────────────────────────────────────────
295
+
296
+ /**
297
+ * Get display label for navigation (short form of title)
298
+ * @returns {string}
299
+ */
300
+ getLabel() {
301
+ return this.label || this.title
302
+ }
303
+
304
+ /**
305
+ * Check if page should be hidden from navigation
306
+ * @returns {boolean}
307
+ */
308
+ isHidden() {
309
+ return this.hidden
310
+ }
311
+
312
+ /**
313
+ * Check if page should appear in header navigation
314
+ * @returns {boolean}
315
+ */
316
+ showInHeader() {
317
+ return !this.hidden && !this.hideInHeader
318
+ }
319
+
320
+ /**
321
+ * Check if page should appear in footer navigation
322
+ * @returns {boolean}
323
+ */
324
+ showInFooter() {
325
+ return !this.hidden && !this.hideInFooter
326
+ }
327
+
328
+ /**
329
+ * Check if header should be rendered on this page
330
+ * @returns {boolean}
331
+ */
332
+ hasHeader() {
333
+ return this.layout.header
334
+ }
335
+
336
+ /**
337
+ * Check if footer should be rendered on this page
338
+ * @returns {boolean}
339
+ */
340
+ hasFooter() {
341
+ return this.layout.footer
342
+ }
343
+
344
+ /**
345
+ * Check if left panel should be rendered on this page
346
+ * @returns {boolean}
347
+ */
348
+ hasLeftPanel() {
349
+ return this.layout.leftPanel
350
+ }
351
+
352
+ /**
353
+ * Check if right panel should be rendered on this page
354
+ * @returns {boolean}
355
+ */
356
+ hasRightPanel() {
357
+ return this.layout.rightPanel
358
+ }
359
+
360
+ /**
361
+ * Check if page has child pages
362
+ * @returns {boolean}
363
+ */
364
+ hasChildren() {
365
+ return this.children.length > 0
74
366
  }
75
367
  }
package/src/uniweb.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import Website from './website.js'
9
+ import Analytics from './analytics.js'
9
10
 
10
11
  export default class Uniweb {
11
12
  constructor(configData) {
@@ -15,6 +16,9 @@ export default class Uniweb {
15
16
  this.foundation = null // The loaded foundation module
16
17
  this.foundationConfig = {} // Configuration from foundation
17
18
  this.language = 'en'
19
+
20
+ // Initialize analytics (disabled by default, configure via site config)
21
+ this.analytics = new Analytics(configData.analytics || {})
18
22
  }
19
23
 
20
24
  /**
package/src/website.js CHANGED
@@ -6,19 +6,47 @@
6
6
 
7
7
  import Page from './page.js'
8
8
 
9
+ // Common locale display names
10
+ const LOCALE_NAMES = {
11
+ en: 'English',
12
+ es: 'Español',
13
+ fr: 'Français',
14
+ de: 'Deutsch',
15
+ it: 'Italiano',
16
+ pt: 'Português',
17
+ nl: 'Nederlands',
18
+ pl: 'Polski',
19
+ ru: 'Русский',
20
+ ja: '日本語',
21
+ ko: '한국어',
22
+ zh: '中文',
23
+ ar: 'العربية'
24
+ }
25
+
9
26
  export default class Website {
10
27
  constructor(websiteData) {
11
- const { pages = [], theme = {}, config = {} } = websiteData
28
+ const { pages = [], theme = {}, config = {}, header, footer, left, right } = websiteData
12
29
 
13
- // Extract special pages (header, footer) and regular pages
14
- this.headerPage = pages.find((p) => p.route === '/@header')
15
- this.footerPage = pages.find((p) => p.route === '/@footer')
30
+ // Site metadata
31
+ this.name = config.name || ''
32
+ this.description = config.description || ''
33
+ this.url = config.url || ''
16
34
 
35
+ // Store special pages (layout areas)
36
+ // These come from top-level properties set by content-collector
37
+ // Fallback to searching pages array for backwards compatibility
38
+ this.headerPage = header || pages.find((p) => p.route === '/@header') || null
39
+ this.footerPage = footer || pages.find((p) => p.route === '/@footer') || null
40
+ this.leftPage = left || pages.find((p) => p.route === '/@left') || null
41
+ this.rightPage = right || pages.find((p) => p.route === '/@right') || null
42
+
43
+ // Filter out special pages from regular pages array
44
+ const specialRoutes = ['/@header', '/@footer', '/@left', '/@right']
17
45
  this.pages = pages
18
- .filter((page) => page.route !== '/@header' && page.route !== '/@footer')
46
+ .filter((page) => !specialRoutes.includes(page.route))
19
47
  .map(
20
48
  (page, index) =>
21
- new Page(page, index, this.headerPage, this.footerPage)
49
+ new Page(page, index, this, this.headerPage, this.footerPage, this.leftPage, this.rightPage)
22
50
  )
23
51
 
24
52
  this.activePage =
@@ -28,11 +56,46 @@ export default class Website {
28
56
  this.pageRoutes = this.pages.map((page) => page.route)
29
57
  this.themeData = theme
30
58
  this.config = config
31
- this.activeLang = config.defaultLanguage || 'en'
32
- this.langs = config.languages || [
33
- { label: 'English', value: 'en' },
34
- { label: 'français', value: 'fr' }
35
- ]
59
+
60
+ // Locale configuration
61
+ this.defaultLocale = config.defaultLanguage || 'en'
62
+ this.activeLocale = config.activeLocale || this.defaultLocale
63
+
64
+ // Build locales list from i18n config
65
+ this.locales = this.buildLocalesList(config)
66
+
67
+ // Legacy language support (for editor multilingual)
68
+ this.activeLang = this.activeLocale
69
+ this.langs = config.languages || this.locales.map(l => ({
70
+ label: l.label,
71
+ value: l.code
72
+ }))
73
+ }
74
+
75
+ /**
76
+ * Build locales list from config
77
+ * @private
78
+ */
79
+ buildLocalesList(config) {
80
+ const defaultLocale = config.defaultLanguage || 'en'
81
+ const i18nLocales = config.i18n?.locales || []
82
+
83
+ // Start with default locale
84
+ const allLocaleCodes = [defaultLocale]
85
+
86
+ // Add translated locales (avoiding duplicates)
87
+ for (const locale of i18nLocales) {
88
+ if (!allLocaleCodes.includes(locale)) {
89
+ allLocaleCodes.push(locale)
90
+ }
91
+ }
92
+
93
+ // Build full locale objects
94
+ return allLocaleCodes.map(code => ({
95
+ code,
96
+ label: LOCALE_NAMES[code] || code.toUpperCase(),
97
+ isDefault: code === defaultLocale
98
+ }))
36
99
  }
37
100
 
38
101
  /**
@@ -88,6 +151,7 @@ export default class Website {
88
151
 
89
152
  /**
90
153
  * Get available languages
154
+ * @deprecated Use getLocales() instead
91
155
  */
92
156
  getLanguages() {
93
157
  return this.langs
@@ -95,11 +159,91 @@ export default class Website {
95
159
 
96
160
  /**
97
161
  * Get current language
162
+ * @deprecated Use getActiveLocale() instead
98
163
  */
99
164
  getLanguage() {
100
165
  return this.activeLang
101
166
  }
102
167
 
168
+ // ─────────────────────────────────────────────────────────────────
169
+ // Locale API (new)
170
+ // ─────────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Get all available locales
174
+ * @returns {Array<{code: string, label: string, isDefault: boolean}>}
175
+ */
176
+ getLocales() {
177
+ return this.locales
178
+ }
179
+
180
+ /**
181
+ * Get currently active locale code
182
+ * @returns {string}
183
+ */
184
+ getActiveLocale() {
185
+ return this.activeLocale
186
+ }
187
+
188
+ /**
189
+ * Get the default locale code
190
+ * @returns {string}
191
+ */
192
+ getDefaultLocale() {
193
+ return this.defaultLocale
194
+ }
195
+
196
+ /**
197
+ * Check if site has multiple locales (useful for showing language switcher)
198
+ * @returns {boolean}
199
+ */
200
+ hasMultipleLocales() {
201
+ return this.locales.length > 1
202
+ }
203
+
204
+ /**
205
+ * Set the active locale
206
+ * @param {string} localeCode - Locale code to activate
207
+ */
208
+ setActiveLocale(localeCode) {
209
+ const locale = this.locales.find(l => l.code === localeCode)
210
+ if (locale) {
211
+ this.activeLocale = localeCode
212
+ this.activeLang = localeCode // Keep legacy in sync
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Build URL for a specific locale
218
+ * @param {string} localeCode - Target locale code
219
+ * @param {string} route - Page route (default: current page route)
220
+ * @returns {string}
221
+ */
222
+ getLocaleUrl(localeCode, route = null) {
223
+ const targetRoute = route || this.activePage?.route || '/'
224
+
225
+ // Default locale uses root path (no prefix)
226
+ if (localeCode === this.defaultLocale) {
227
+ return targetRoute
228
+ }
229
+
230
+ // Other locales use /locale/ prefix
231
+ if (targetRoute === '/') {
232
+ return `/${localeCode}/`
233
+ }
234
+
235
+ return `/${localeCode}${targetRoute}`
236
+ }
237
+
238
+ /**
239
+ * Get locale info by code
240
+ * @param {string} localeCode - Locale code
241
+ * @returns {Object|undefined} Locale object or undefined
242
+ */
243
+ getLocale(localeCode) {
244
+ return this.locales.find(l => l.code === localeCode)
245
+ }
246
+
103
247
  /**
104
248
  * Localize a value
105
249
  * @param {any} val - Value to localize (object with lang keys, or string)
@@ -137,8 +281,60 @@ export default class Website {
137
281
  return defaultVal
138
282
  }
139
283
 
284
+ // ─────────────────────────────────────────────────────────────────
285
+ // Search API
286
+ // ─────────────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Check if search is enabled for this site
290
+ * @returns {boolean}
291
+ */
292
+ isSearchEnabled() {
293
+ // Search is enabled by default unless explicitly disabled
294
+ return this.config?.search?.enabled !== false
295
+ }
296
+
297
+ /**
298
+ * Get search configuration
299
+ * @returns {Object} Search configuration
300
+ */
301
+ getSearchConfig() {
302
+ const config = this.config?.search || {}
303
+
304
+ return {
305
+ enabled: this.isSearchEnabled(),
306
+ indexUrl: this.getSearchIndexUrl(),
307
+ locale: this.getActiveLocale(),
308
+ include: {
309
+ pages: config.include?.pages !== false,
310
+ sections: config.include?.sections !== false,
311
+ headings: config.include?.headings !== false,
312
+ paragraphs: config.include?.paragraphs !== false,
313
+ links: config.include?.links !== false,
314
+ lists: config.include?.lists !== false
315
+ },
316
+ exclude: {
317
+ routes: config.exclude?.routes || [],
318
+ components: config.exclude?.components || []
319
+ }
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Get the URL for the search index file
325
+ * @returns {string} URL to fetch the search index
326
+ */
327
+ getSearchIndexUrl() {
328
+ const locale = this.getActiveLocale()
329
+ const isDefault = locale === this.getDefaultLocale()
330
+
331
+ // Default locale uses root path, others use locale prefix
332
+ return isDefault ? '/search-index.json' : `/${locale}/search-index.json`
333
+ }
334
+
140
335
  /**
141
336
  * Get search data for all pages
337
+ * @deprecated Use getSearchConfig() and fetch the search index instead
142
338
  */
143
339
  getSearchData() {
144
340
  return this.pages.map((page) => ({
@@ -154,4 +350,111 @@ export default class Website {
154
350
  .join('\n')
155
351
  }))
156
352
  }
353
+
354
+ // ─────────────────────────────────────────────────────────────────
355
+ // Page Hierarchy API (for navigation components)
356
+ // ─────────────────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * Get page hierarchy for building navigation (navbar, footer, sitemap)
360
+ *
361
+ * This is the primary API for navigation components. It returns pages
362
+ * filtered and formatted for navigation use.
363
+ *
364
+ * @param {Object} options - Configuration options
365
+ * @param {boolean} [options.nested=true] - Return nested hierarchy (with children) or flat list
366
+ * @param {string} [options.for] - Filter for specific navigation: 'header', 'footer', or undefined (all)
367
+ * @param {boolean} [options.includeHidden=false] - Include hidden pages
368
+ * @param {function} [options.filter] - Custom filter function (page) => boolean
369
+ * @param {function} [options.sort] - Custom sort function (a, b) => number
370
+ * @returns {Array<Object>} Array of page info objects for navigation
371
+ *
372
+ * @example
373
+ * // Get pages for header navigation
374
+ * const headerPages = website.getPageHierarchy({ for: 'header' })
375
+ *
376
+ * // Get flat list of all pages
377
+ * const allPages = website.getPageHierarchy({ nested: false, includeHidden: true })
378
+ *
379
+ * // Custom filtering
380
+ * const topLevel = website.getPageHierarchy({
381
+ * filter: (page) => page.order < 10
382
+ * })
383
+ */
384
+ getPageHierarchy(options = {}) {
385
+ const {
386
+ nested = true,
387
+ for: navType,
388
+ includeHidden = false,
389
+ filter: customFilter,
390
+ sort: customSort
391
+ } = options
392
+
393
+ // Filter pages based on navigation type and visibility
394
+ let filteredPages = this.pages.filter(page => {
395
+ // Always exclude special pages (header/footer are already separated)
396
+ if (page.route.startsWith('/@')) return false
397
+
398
+ // Check visibility based on navigation type
399
+ if (!includeHidden) {
400
+ if (page.hidden) return false
401
+ if (navType === 'header' && page.hideInHeader) return false
402
+ if (navType === 'footer' && page.hideInFooter) return false
403
+ }
404
+
405
+ // Apply custom filter if provided
406
+ if (customFilter && !customFilter(page)) return false
407
+
408
+ return true
409
+ })
410
+
411
+ // Apply custom sort or default to order
412
+ if (customSort) {
413
+ filteredPages.sort(customSort)
414
+ }
415
+ // Already sorted by order in constructor, so no need to re-sort
416
+
417
+ // Build page info objects
418
+ const buildPageInfo = (page) => ({
419
+ id: page.id,
420
+ route: page.route === '/' ? '' : page.route,
421
+ title: page.title,
422
+ label: page.getLabel(),
423
+ description: page.description,
424
+ order: page.order,
425
+ hasContent: page.getBodyBlocks().length > 0,
426
+ children: nested && page.hasChildren()
427
+ ? page.children.map(buildPageInfo)
428
+ : []
429
+ })
430
+
431
+ return filteredPages.map(buildPageInfo)
432
+ }
433
+
434
+ /**
435
+ * Get pages for header navigation
436
+ * Convenience method equivalent to getPageHierarchy({ for: 'header' })
437
+ * @returns {Array<Object>}
438
+ */
439
+ getHeaderPages() {
440
+ return this.getPageHierarchy({ for: 'header' })
441
+ }
442
+
443
+ /**
444
+ * Get pages for footer navigation
445
+ * Convenience method equivalent to getPageHierarchy({ for: 'footer' })
446
+ * @returns {Array<Object>}
447
+ */
448
+ getFooterPages() {
449
+ return this.getPageHierarchy({ for: 'footer' })
450
+ }
451
+
452
+ /**
453
+ * Get flat list of all pages (for sitemaps, search, etc.)
454
+ * @param {boolean} includeHidden - Include hidden pages
455
+ * @returns {Array<Object>}
456
+ */
457
+ getAllPages(includeHidden = false) {
458
+ return this.getPageHierarchy({ nested: false, includeHidden })
459
+ }
157
460
  }