@uniweb/core 0.1.4 → 0.1.5

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.5",
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/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,143 @@
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, 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 (set by Website constructor)
52
+ this.website = null
53
+ this.site = null // 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
25
71
  */
26
- buildPageBlocks(body, header, footer) {
27
- const headerSection = header?.[0]
28
- const footerSection = footer?.[0]
29
- const bodySections = body || []
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
98
+ */
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) => new Block(section, `${prefix}-${index}`))
103
+ }
30
104
 
31
105
  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
106
+ header: buildBlocks(header, 'header'),
107
+ body: (body || []).map((section, index) => new Block(section, index)),
108
+ footer: buildBlocks(footer, 'footer'),
109
+ left: buildBlocks(left, 'left'),
110
+ right: buildBlocks(right, 'right')
37
111
  }
38
112
  }
39
113
 
114
+ /**
115
+ * Get all block groups (for Layout component)
116
+ * @returns {Object} { header, body, footer, left, right }
117
+ */
118
+ getBlockGroups() {
119
+ return this.pageBlocks
120
+ }
121
+
40
122
  /**
41
123
  * Get all blocks (header, body, footer) as flat array
124
+ * Respects page layout preferences (hasHeader, hasFooter, etc.)
42
125
  * @returns {Block[]}
43
126
  */
44
127
  getPageBlocks() {
45
- return [
46
- this.pageBlocks.header,
47
- ...this.pageBlocks.body,
48
- this.pageBlocks.footer
49
- ].filter(Boolean)
128
+ const blocks = []
129
+
130
+ if (this.hasHeader() && this.pageBlocks.header) {
131
+ blocks.push(...this.pageBlocks.header)
132
+ }
133
+
134
+ blocks.push(...this.pageBlocks.body)
135
+
136
+ if (this.hasFooter() && this.pageBlocks.footer) {
137
+ blocks.push(...this.pageBlocks.footer)
138
+ }
139
+
140
+ return blocks
50
141
  }
51
142
 
52
143
  /**
@@ -58,18 +149,151 @@ export default class Page {
58
149
  }
59
150
 
60
151
  /**
61
- * Get header block
152
+ * Get header blocks (respects layout preference)
153
+ * @returns {Block[]|null}
154
+ */
155
+ getHeaderBlocks() {
156
+ if (!this.hasHeader()) return null
157
+ return this.pageBlocks.header
158
+ }
159
+
160
+ /**
161
+ * Get footer blocks (respects layout preference)
162
+ * @returns {Block[]|null}
163
+ */
164
+ getFooterBlocks() {
165
+ if (!this.hasFooter()) return null
166
+ return this.pageBlocks.footer
167
+ }
168
+
169
+ /**
170
+ * Get left panel blocks (respects layout preference)
171
+ * @returns {Block[]|null}
172
+ */
173
+ getLeftBlocks() {
174
+ if (!this.hasLeftPanel()) return null
175
+ return this.pageBlocks.left
176
+ }
177
+
178
+ /**
179
+ * Get right panel blocks (respects layout preference)
180
+ * @returns {Block[]|null}
181
+ */
182
+ getRightBlocks() {
183
+ if (!this.hasRightPanel()) return null
184
+ return this.pageBlocks.right
185
+ }
186
+
187
+ /**
188
+ * Get header block (legacy - returns first block)
62
189
  * @returns {Block|null}
190
+ * @deprecated Use getHeaderBlocks() instead
63
191
  */
64
192
  getHeader() {
65
- return this.pageBlocks.header
193
+ return this.pageBlocks.header?.[0] || null
66
194
  }
67
195
 
68
196
  /**
69
- * Get footer block
197
+ * Get footer block (legacy - returns first block)
70
198
  * @returns {Block|null}
199
+ * @deprecated Use getFooterBlocks() instead
71
200
  */
72
201
  getFooter() {
73
- return this.pageBlocks.footer
202
+ return this.pageBlocks.footer?.[0] || null
203
+ }
204
+
205
+ /**
206
+ * Reset block states (for scroll restoration)
207
+ */
208
+ resetBlockStates() {
209
+ const allBlocks = [
210
+ ...(this.pageBlocks.header || []),
211
+ ...this.pageBlocks.body,
212
+ ...(this.pageBlocks.footer || []),
213
+ ...(this.pageBlocks.left || []),
214
+ ...(this.pageBlocks.right || [])
215
+ ]
216
+
217
+ for (const block of allBlocks) {
218
+ if (typeof block.initState === 'function') {
219
+ block.initState()
220
+ }
221
+ }
222
+ }
223
+
224
+ // ─────────────────────────────────────────────────────────────────
225
+ // Navigation and Layout Helpers
226
+ // ─────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Get display label for navigation (short form of title)
230
+ * @returns {string}
231
+ */
232
+ getLabel() {
233
+ return this.label || this.title
234
+ }
235
+
236
+ /**
237
+ * Check if page should be hidden from navigation
238
+ * @returns {boolean}
239
+ */
240
+ isHidden() {
241
+ return this.hidden
242
+ }
243
+
244
+ /**
245
+ * Check if page should appear in header navigation
246
+ * @returns {boolean}
247
+ */
248
+ showInHeader() {
249
+ return !this.hidden && !this.hideInHeader
250
+ }
251
+
252
+ /**
253
+ * Check if page should appear in footer navigation
254
+ * @returns {boolean}
255
+ */
256
+ showInFooter() {
257
+ return !this.hidden && !this.hideInFooter
258
+ }
259
+
260
+ /**
261
+ * Check if header should be rendered on this page
262
+ * @returns {boolean}
263
+ */
264
+ hasHeader() {
265
+ return this.layout.header
266
+ }
267
+
268
+ /**
269
+ * Check if footer should be rendered on this page
270
+ * @returns {boolean}
271
+ */
272
+ hasFooter() {
273
+ return this.layout.footer
274
+ }
275
+
276
+ /**
277
+ * Check if left panel should be rendered on this page
278
+ * @returns {boolean}
279
+ */
280
+ hasLeftPanel() {
281
+ return this.layout.leftPanel
282
+ }
283
+
284
+ /**
285
+ * Check if right panel should be rendered on this page
286
+ * @returns {boolean}
287
+ */
288
+ hasRightPanel() {
289
+ return this.layout.rightPanel
290
+ }
291
+
292
+ /**
293
+ * Check if page has child pages
294
+ * @returns {boolean}
295
+ */
296
+ hasChildren() {
297
+ return this.children.length > 0
74
298
  }
75
299
  }
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,21 +6,55 @@
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
29
+
30
+ // Site metadata
31
+ this.name = config.name || ''
32
+ this.description = config.description || ''
33
+ this.url = config.url || ''
12
34
 
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')
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
16
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.headerPage, this.footerPage, this.leftPage, this.rightPage)
22
50
  )
23
51
 
52
+ // Set reference from pages back to website
53
+ for (const page of this.pages) {
54
+ page.website = this
55
+ page.site = this // Alias
56
+ }
57
+
24
58
  this.activePage =
25
59
  this.pages.find((page) => page.route === '/' || page.route === '/index') ||
26
60
  this.pages[0]
@@ -28,11 +62,46 @@ export default class Website {
28
62
  this.pageRoutes = this.pages.map((page) => page.route)
29
63
  this.themeData = theme
30
64
  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
- ]
65
+
66
+ // Locale configuration
67
+ this.defaultLocale = config.defaultLanguage || 'en'
68
+ this.activeLocale = config.activeLocale || this.defaultLocale
69
+
70
+ // Build locales list from i18n config
71
+ this.locales = this.buildLocalesList(config)
72
+
73
+ // Legacy language support (for editor multilingual)
74
+ this.activeLang = this.activeLocale
75
+ this.langs = config.languages || this.locales.map(l => ({
76
+ label: l.label,
77
+ value: l.code
78
+ }))
79
+ }
80
+
81
+ /**
82
+ * Build locales list from config
83
+ * @private
84
+ */
85
+ buildLocalesList(config) {
86
+ const defaultLocale = config.defaultLanguage || 'en'
87
+ const i18nLocales = config.i18n?.locales || []
88
+
89
+ // Start with default locale
90
+ const allLocaleCodes = [defaultLocale]
91
+
92
+ // Add translated locales (avoiding duplicates)
93
+ for (const locale of i18nLocales) {
94
+ if (!allLocaleCodes.includes(locale)) {
95
+ allLocaleCodes.push(locale)
96
+ }
97
+ }
98
+
99
+ // Build full locale objects
100
+ return allLocaleCodes.map(code => ({
101
+ code,
102
+ label: LOCALE_NAMES[code] || code.toUpperCase(),
103
+ isDefault: code === defaultLocale
104
+ }))
36
105
  }
37
106
 
38
107
  /**
@@ -88,6 +157,7 @@ export default class Website {
88
157
 
89
158
  /**
90
159
  * Get available languages
160
+ * @deprecated Use getLocales() instead
91
161
  */
92
162
  getLanguages() {
93
163
  return this.langs
@@ -95,11 +165,91 @@ export default class Website {
95
165
 
96
166
  /**
97
167
  * Get current language
168
+ * @deprecated Use getActiveLocale() instead
98
169
  */
99
170
  getLanguage() {
100
171
  return this.activeLang
101
172
  }
102
173
 
174
+ // ─────────────────────────────────────────────────────────────────
175
+ // Locale API (new)
176
+ // ─────────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Get all available locales
180
+ * @returns {Array<{code: string, label: string, isDefault: boolean}>}
181
+ */
182
+ getLocales() {
183
+ return this.locales
184
+ }
185
+
186
+ /**
187
+ * Get currently active locale code
188
+ * @returns {string}
189
+ */
190
+ getActiveLocale() {
191
+ return this.activeLocale
192
+ }
193
+
194
+ /**
195
+ * Get the default locale code
196
+ * @returns {string}
197
+ */
198
+ getDefaultLocale() {
199
+ return this.defaultLocale
200
+ }
201
+
202
+ /**
203
+ * Check if site has multiple locales (useful for showing language switcher)
204
+ * @returns {boolean}
205
+ */
206
+ hasMultipleLocales() {
207
+ return this.locales.length > 1
208
+ }
209
+
210
+ /**
211
+ * Set the active locale
212
+ * @param {string} localeCode - Locale code to activate
213
+ */
214
+ setActiveLocale(localeCode) {
215
+ const locale = this.locales.find(l => l.code === localeCode)
216
+ if (locale) {
217
+ this.activeLocale = localeCode
218
+ this.activeLang = localeCode // Keep legacy in sync
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Build URL for a specific locale
224
+ * @param {string} localeCode - Target locale code
225
+ * @param {string} route - Page route (default: current page route)
226
+ * @returns {string}
227
+ */
228
+ getLocaleUrl(localeCode, route = null) {
229
+ const targetRoute = route || this.activePage?.route || '/'
230
+
231
+ // Default locale uses root path (no prefix)
232
+ if (localeCode === this.defaultLocale) {
233
+ return targetRoute
234
+ }
235
+
236
+ // Other locales use /locale/ prefix
237
+ if (targetRoute === '/') {
238
+ return `/${localeCode}/`
239
+ }
240
+
241
+ return `/${localeCode}${targetRoute}`
242
+ }
243
+
244
+ /**
245
+ * Get locale info by code
246
+ * @param {string} localeCode - Locale code
247
+ * @returns {Object|undefined} Locale object or undefined
248
+ */
249
+ getLocale(localeCode) {
250
+ return this.locales.find(l => l.code === localeCode)
251
+ }
252
+
103
253
  /**
104
254
  * Localize a value
105
255
  * @param {any} val - Value to localize (object with lang keys, or string)
@@ -154,4 +304,111 @@ export default class Website {
154
304
  .join('\n')
155
305
  }))
156
306
  }
307
+
308
+ // ─────────────────────────────────────────────────────────────────
309
+ // Page Hierarchy API (for navigation components)
310
+ // ─────────────────────────────────────────────────────────────────
311
+
312
+ /**
313
+ * Get page hierarchy for building navigation (navbar, footer, sitemap)
314
+ *
315
+ * This is the primary API for navigation components. It returns pages
316
+ * filtered and formatted for navigation use.
317
+ *
318
+ * @param {Object} options - Configuration options
319
+ * @param {boolean} [options.nested=true] - Return nested hierarchy (with children) or flat list
320
+ * @param {string} [options.for] - Filter for specific navigation: 'header', 'footer', or undefined (all)
321
+ * @param {boolean} [options.includeHidden=false] - Include hidden pages
322
+ * @param {function} [options.filter] - Custom filter function (page) => boolean
323
+ * @param {function} [options.sort] - Custom sort function (a, b) => number
324
+ * @returns {Array<Object>} Array of page info objects for navigation
325
+ *
326
+ * @example
327
+ * // Get pages for header navigation
328
+ * const headerPages = website.getPageHierarchy({ for: 'header' })
329
+ *
330
+ * // Get flat list of all pages
331
+ * const allPages = website.getPageHierarchy({ nested: false, includeHidden: true })
332
+ *
333
+ * // Custom filtering
334
+ * const topLevel = website.getPageHierarchy({
335
+ * filter: (page) => page.order < 10
336
+ * })
337
+ */
338
+ getPageHierarchy(options = {}) {
339
+ const {
340
+ nested = true,
341
+ for: navType,
342
+ includeHidden = false,
343
+ filter: customFilter,
344
+ sort: customSort
345
+ } = options
346
+
347
+ // Filter pages based on navigation type and visibility
348
+ let filteredPages = this.pages.filter(page => {
349
+ // Always exclude special pages (header/footer are already separated)
350
+ if (page.route.startsWith('/@')) return false
351
+
352
+ // Check visibility based on navigation type
353
+ if (!includeHidden) {
354
+ if (page.hidden) return false
355
+ if (navType === 'header' && page.hideInHeader) return false
356
+ if (navType === 'footer' && page.hideInFooter) return false
357
+ }
358
+
359
+ // Apply custom filter if provided
360
+ if (customFilter && !customFilter(page)) return false
361
+
362
+ return true
363
+ })
364
+
365
+ // Apply custom sort or default to order
366
+ if (customSort) {
367
+ filteredPages.sort(customSort)
368
+ }
369
+ // Already sorted by order in constructor, so no need to re-sort
370
+
371
+ // Build page info objects
372
+ const buildPageInfo = (page) => ({
373
+ id: page.id,
374
+ route: page.route === '/' ? '' : page.route,
375
+ title: page.title,
376
+ label: page.getLabel(),
377
+ description: page.description,
378
+ order: page.order,
379
+ hasContent: page.getBodyBlocks().length > 0,
380
+ children: nested && page.hasChildren()
381
+ ? page.children.map(buildPageInfo)
382
+ : []
383
+ })
384
+
385
+ return filteredPages.map(buildPageInfo)
386
+ }
387
+
388
+ /**
389
+ * Get pages for header navigation
390
+ * Convenience method equivalent to getPageHierarchy({ for: 'header' })
391
+ * @returns {Array<Object>}
392
+ */
393
+ getHeaderPages() {
394
+ return this.getPageHierarchy({ for: 'header' })
395
+ }
396
+
397
+ /**
398
+ * Get pages for footer navigation
399
+ * Convenience method equivalent to getPageHierarchy({ for: 'footer' })
400
+ * @returns {Array<Object>}
401
+ */
402
+ getFooterPages() {
403
+ return this.getPageHierarchy({ for: 'footer' })
404
+ }
405
+
406
+ /**
407
+ * Get flat list of all pages (for sitemaps, search, etc.)
408
+ * @param {boolean} includeHidden - Include hidden pages
409
+ * @returns {Array<Object>}
410
+ */
411
+ getAllPages(includeHidden = false) {
412
+ return this.getPageHierarchy({ nested: false, includeHidden })
413
+ }
157
414
  }