@uniweb/core 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
package/src/block.js CHANGED
@@ -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/page.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import Block from './block.js'
9
9
 
10
10
  export default class Page {
11
- constructor(pageData, id, pageHeader, pageFooter, pageLeft, pageRight) {
11
+ constructor(pageData, id, website, pageHeader, pageFooter, pageLeft, pageRight) {
12
12
  this.id = id
13
13
  this.route = pageData.route
14
14
  this.title = pageData.title || ''
@@ -48,9 +48,9 @@ export default class Page {
48
48
  // Child pages (for nested hierarchy) - populated by Website
49
49
  this.children = []
50
50
 
51
- // Back-reference to website (set by Website constructor)
52
- this.website = null
53
- this.site = null // Alias
51
+ // Back-reference to website
52
+ this.website = website
53
+ this.site = website // Alias
54
54
 
55
55
  // Scroll position memory (for navigation restoration)
56
56
  this.scrollY = 0
@@ -99,18 +99,46 @@ export default class Page {
99
99
  buildPageBlocks(body, header, footer, left, right) {
100
100
  const buildBlocks = (sections, prefix) => {
101
101
  if (!sections || sections.length === 0) return null
102
- return sections.map((section, index) => new Block(section, `${prefix}-${index}`))
102
+ return sections.map((section, index) => {
103
+ const block = new Block(section, `${prefix}-${index}`)
104
+ this.initBlockReferences(block)
105
+ return block
106
+ })
103
107
  }
104
108
 
109
+ const bodyBlocks = (body || []).map((section, index) => {
110
+ const block = new Block(section, index)
111
+ this.initBlockReferences(block)
112
+ return block
113
+ })
114
+
105
115
  return {
106
116
  header: buildBlocks(header, 'header'),
107
- body: (body || []).map((section, index) => new Block(section, index)),
117
+ body: bodyBlocks,
108
118
  footer: buildBlocks(footer, 'footer'),
109
119
  left: buildBlocks(left, 'left'),
110
120
  right: buildBlocks(right, 'right')
111
121
  }
112
122
  }
113
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
+ }
139
+ }
140
+ }
141
+
114
142
  /**
115
143
  * Get all block groups (for Layout component)
116
144
  * @returns {Object} { header, body, footer, left, right }
@@ -119,6 +147,46 @@ export default class Page {
119
147
  return this.pageBlocks
120
148
  }
121
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
+
122
190
  /**
123
191
  * Get all blocks (header, body, footer) as flat array
124
192
  * Respects page layout preferences (hasHeader, hasFooter, etc.)
package/src/website.js CHANGED
@@ -46,14 +46,11 @@ export default class Website {
46
46
  .filter((page) => !specialRoutes.includes(page.route))
47
47
  .map(
48
48
  (page, index) =>
49
- new Page(page, index, this.headerPage, this.footerPage, this.leftPage, this.rightPage)
49
+ new Page(page, index, this, this.headerPage, this.footerPage, this.leftPage, this.rightPage)
50
50
  )
51
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
- }
52
+ // Build parent-child relationships based on route structure
53
+ this.buildPageHierarchy()
57
54
 
58
55
  this.activePage =
59
56
  this.pages.find((page) => page.route === '/' || page.route === '/index') ||
@@ -104,6 +101,53 @@ export default class Website {
104
101
  }))
105
102
  }
106
103
 
104
+ /**
105
+ * Build parent-child relationships between pages based on route structure
106
+ * E.g., /getting-started/installation is a child of /getting-started
107
+ * @private
108
+ */
109
+ buildPageHierarchy() {
110
+ // Sort pages by route depth (parents before children)
111
+ const sortedPages = [...this.pages].sort((a, b) => {
112
+ const depthA = (a.route.match(/\//g) || []).length
113
+ const depthB = (b.route.match(/\//g) || []).length
114
+ return depthA - depthB
115
+ })
116
+
117
+ // Build a map of route to page for quick lookup
118
+ const pageMap = new Map()
119
+ for (const page of sortedPages) {
120
+ pageMap.set(page.route, page)
121
+ }
122
+
123
+ // For each page, find its parent and add it as a child
124
+ for (const page of sortedPages) {
125
+ 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
130
+ const segments = route.split('/').filter(Boolean)
131
+ if (segments.length <= 1) continue // Root-level pages have no parent
132
+
133
+ // Build parent route
134
+ const parentRoute = '/' + segments.slice(0, -1).join('/')
135
+ const parent = pageMap.get(parentRoute)
136
+
137
+ if (parent) {
138
+ parent.children.push(page)
139
+ page.parent = parent
140
+ }
141
+ }
142
+
143
+ // Sort children by order
144
+ for (const page of this.pages) {
145
+ if (page.children.length > 0) {
146
+ page.children.sort((a, b) => (a.order || 0) - (b.order || 0))
147
+ }
148
+ }
149
+ }
150
+
107
151
  /**
108
152
  * Get page by route
109
153
  * @param {string} route
@@ -287,8 +331,60 @@ export default class Website {
287
331
  return defaultVal
288
332
  }
289
333
 
334
+ // ─────────────────────────────────────────────────────────────────
335
+ // Search API
336
+ // ─────────────────────────────────────────────────────────────────
337
+
338
+ /**
339
+ * Check if search is enabled for this site
340
+ * @returns {boolean}
341
+ */
342
+ isSearchEnabled() {
343
+ // Search is enabled by default unless explicitly disabled
344
+ return this.config?.search?.enabled !== false
345
+ }
346
+
347
+ /**
348
+ * Get search configuration
349
+ * @returns {Object} Search configuration
350
+ */
351
+ getSearchConfig() {
352
+ const config = this.config?.search || {}
353
+
354
+ return {
355
+ enabled: this.isSearchEnabled(),
356
+ indexUrl: this.getSearchIndexUrl(),
357
+ locale: this.getActiveLocale(),
358
+ include: {
359
+ pages: config.include?.pages !== false,
360
+ sections: config.include?.sections !== false,
361
+ headings: config.include?.headings !== false,
362
+ paragraphs: config.include?.paragraphs !== false,
363
+ links: config.include?.links !== false,
364
+ lists: config.include?.lists !== false
365
+ },
366
+ exclude: {
367
+ routes: config.exclude?.routes || [],
368
+ components: config.exclude?.components || []
369
+ }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Get the URL for the search index file
375
+ * @returns {string} URL to fetch the search index
376
+ */
377
+ getSearchIndexUrl() {
378
+ const locale = this.getActiveLocale()
379
+ const isDefault = locale === this.getDefaultLocale()
380
+
381
+ // Default locale uses root path, others use locale prefix
382
+ return isDefault ? '/search-index.json' : `/${locale}/search-index.json`
383
+ }
384
+
290
385
  /**
291
386
  * Get search data for all pages
387
+ * @deprecated Use getSearchConfig() and fetch the search index instead
292
388
  */
293
389
  getSearchData() {
294
390
  return this.pages.map((page) => ({
@@ -345,7 +441,7 @@ export default class Website {
345
441
  } = options
346
442
 
347
443
  // Filter pages based on navigation type and visibility
348
- let filteredPages = this.pages.filter(page => {
444
+ const isPageVisible = (page) => {
349
445
  // Always exclude special pages (header/footer are already separated)
350
446
  if (page.route.startsWith('/@')) return false
351
447
 
@@ -360,7 +456,15 @@ export default class Website {
360
456
  if (customFilter && !customFilter(page)) return false
361
457
 
362
458
  return true
363
- })
459
+ }
460
+
461
+ let filteredPages = this.pages.filter(isPageVisible)
462
+
463
+ // When nested, only include root-level pages at top level
464
+ // (children will be nested inside their parents)
465
+ if (nested) {
466
+ filteredPages = filteredPages.filter(page => !page.parent)
467
+ }
364
468
 
365
469
  // Apply custom sort or default to order
366
470
  if (customSort) {
@@ -378,7 +482,7 @@ export default class Website {
378
482
  order: page.order,
379
483
  hasContent: page.getBodyBlocks().length > 0,
380
484
  children: nested && page.hasChildren()
381
- ? page.children.map(buildPageInfo)
485
+ ? page.children.filter(isPageVisible).map(buildPageInfo)
382
486
  : []
383
487
  })
384
488