@uniweb/core 0.3.10 → 0.4.0
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 +2 -2
- package/src/block.js +45 -78
- package/src/datastore.js +113 -0
- package/src/entity-store.js +276 -0
- package/src/index.js +2 -0
- package/src/page.js +7 -0
- package/src/singularize.js +40 -0
- package/src/website.js +42 -97
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"jest": "^29.7.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@uniweb/semantic-parser": "1.0
|
|
33
|
+
"@uniweb/semantic-parser": "1.1.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
package/src/block.js
CHANGED
|
@@ -43,10 +43,27 @@ export default class Block {
|
|
|
43
43
|
// Block configuration
|
|
44
44
|
const blockConfig = blockData.params || blockData.config || {}
|
|
45
45
|
this.preset = blockData.preset
|
|
46
|
-
|
|
46
|
+
|
|
47
|
+
// Normalize theme: supports string ("light") or object ({ mode, ...tokenOverrides })
|
|
48
|
+
const rawTheme = blockConfig.theme
|
|
49
|
+
if (rawTheme && typeof rawTheme === 'object') {
|
|
50
|
+
const { mode, ...overrides } = rawTheme
|
|
51
|
+
this.themeName = mode || 'light'
|
|
52
|
+
this.contextOverrides = Object.keys(overrides).length > 0 ? overrides : null
|
|
53
|
+
} else {
|
|
54
|
+
this.themeName = rawTheme || 'light'
|
|
55
|
+
this.contextOverrides = null
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
this.standardOptions = blockConfig.standardOptions || {}
|
|
48
59
|
this.properties = blockConfig.properties || blockConfig
|
|
49
60
|
|
|
61
|
+
// Normalize params.theme to string so components always see "light"/"dark"/"medium",
|
|
62
|
+
// not the raw object. Done after properties assignment to avoid mutating source data.
|
|
63
|
+
if (this.properties.theme && typeof this.properties.theme === 'object') {
|
|
64
|
+
this.properties = { ...this.properties, theme: this.themeName }
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
// Extract background from params into standardOptions
|
|
51
68
|
// Content authors set background in section frontmatter; the runtime
|
|
52
69
|
// reads it from standardOptions to render the Background component.
|
|
@@ -67,9 +84,9 @@ export default class Block {
|
|
|
67
84
|
// Supports local files (path) or remote URLs (url)
|
|
68
85
|
this.fetch = blockData.fetch || null
|
|
69
86
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
this.
|
|
87
|
+
// Data loading state — set by BlockRenderer when a runtime fetch is in progress
|
|
88
|
+
// Components check this to show loading UI (spinners, skeletons)
|
|
89
|
+
this.dataLoading = false
|
|
73
90
|
|
|
74
91
|
// Dynamic route context (params from URL matching)
|
|
75
92
|
// Set when accessing a dynamic page like /blog/:slug -> /blog/my-post
|
|
@@ -89,7 +106,7 @@ export default class Block {
|
|
|
89
106
|
* Supports multiple content formats:
|
|
90
107
|
* 1. Pre-parsed groups structure (from editor)
|
|
91
108
|
* 2. ProseMirror document (from markdown collection)
|
|
92
|
-
* 3.
|
|
109
|
+
* 3. Plain object (passed through directly)
|
|
93
110
|
*
|
|
94
111
|
* Uses @uniweb/semantic-parser for rich content extraction including:
|
|
95
112
|
* - Pretitle detection (H3 before H1)
|
|
@@ -108,20 +125,18 @@ export default class Block {
|
|
|
108
125
|
return this.extractFromProseMirror(content)
|
|
109
126
|
}
|
|
110
127
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
128
|
+
// Plain object content — pass through directly.
|
|
129
|
+
// guaranteeContentStructure() in prepare-props will fill in missing fields.
|
|
113
130
|
if (content && typeof content === 'object' && !Array.isArray(content)) {
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
_isPoc: true,
|
|
117
|
-
_pocContent: content
|
|
118
|
-
}
|
|
131
|
+
return content
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
// Fallback
|
|
134
|
+
// Fallback — empty flat structure
|
|
122
135
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
title: '',
|
|
137
|
+
paragraphs: [],
|
|
138
|
+
items: [],
|
|
139
|
+
sequence: []
|
|
125
140
|
}
|
|
126
141
|
}
|
|
127
142
|
|
|
@@ -315,6 +330,7 @@ export default class Block {
|
|
|
315
330
|
return {
|
|
316
331
|
type: this.type,
|
|
317
332
|
theme: this.themeName,
|
|
333
|
+
contextOverrides: this.contextOverrides,
|
|
318
334
|
state: this.state,
|
|
319
335
|
context: this.context
|
|
320
336
|
}
|
|
@@ -381,63 +397,6 @@ export default class Block {
|
|
|
381
397
|
return this.dynamicContext
|
|
382
398
|
}
|
|
383
399
|
|
|
384
|
-
/**
|
|
385
|
-
* Get the current item from cascaded data using dynamic route params
|
|
386
|
-
* Looks up the item in cascadedData that matches the URL param value
|
|
387
|
-
*
|
|
388
|
-
* @param {string} [schema] - Schema name to look up (e.g., 'articles'). If omitted, uses parentSchema from dynamicContext.
|
|
389
|
-
* @returns {Object|null} The matched item, or null if not found
|
|
390
|
-
*
|
|
391
|
-
* @example
|
|
392
|
-
* // URL: /blog/my-post, cascadedData: { articles: [{slug: 'my-post', title: 'My Post'}, ...] }
|
|
393
|
-
* block.getCurrentItem('articles')
|
|
394
|
-
* // { slug: 'my-post', title: 'My Post', ... }
|
|
395
|
-
*/
|
|
396
|
-
getCurrentItem(schema) {
|
|
397
|
-
const ctx = this.dynamicContext
|
|
398
|
-
if (!ctx) return null
|
|
399
|
-
|
|
400
|
-
const { paramName, paramValue } = ctx
|
|
401
|
-
|
|
402
|
-
// If schema not provided, try to infer from cascadedData keys
|
|
403
|
-
const lookupSchema = schema || this._inferSchema()
|
|
404
|
-
if (!lookupSchema) return null
|
|
405
|
-
|
|
406
|
-
const items = this.cascadedData[lookupSchema]
|
|
407
|
-
if (!Array.isArray(items)) return null
|
|
408
|
-
|
|
409
|
-
// Find item where the param field matches the URL value
|
|
410
|
-
return items.find(item => String(item[paramName]) === String(paramValue)) || null
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Get all items from cascaded data for the dynamic route's schema
|
|
415
|
-
*
|
|
416
|
-
* @param {string} [schema] - Schema name to look up. If omitted, uses parentSchema from dynamicContext.
|
|
417
|
-
* @returns {Array} Array of items, or empty array if not found
|
|
418
|
-
*/
|
|
419
|
-
getAllItems(schema) {
|
|
420
|
-
const lookupSchema = schema || this._inferSchema()
|
|
421
|
-
if (!lookupSchema) return []
|
|
422
|
-
|
|
423
|
-
const items = this.cascadedData[lookupSchema]
|
|
424
|
-
return Array.isArray(items) ? items : []
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Infer the schema name from cascaded data keys
|
|
429
|
-
* Looks for the first array in cascadedData
|
|
430
|
-
* @private
|
|
431
|
-
*/
|
|
432
|
-
_inferSchema() {
|
|
433
|
-
for (const key of Object.keys(this.cascadedData)) {
|
|
434
|
-
if (Array.isArray(this.cascadedData[key])) {
|
|
435
|
-
return key
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
return null
|
|
439
|
-
}
|
|
440
|
-
|
|
441
400
|
/**
|
|
442
401
|
* Normalize a background value from section frontmatter
|
|
443
402
|
*
|
|
@@ -451,15 +410,23 @@ export default class Block {
|
|
|
451
410
|
* @returns {Object} Normalized background config with mode
|
|
452
411
|
*/
|
|
453
412
|
static normalizeBackground(raw) {
|
|
454
|
-
// String
|
|
413
|
+
// String shorthand — classify by content
|
|
455
414
|
if (typeof raw === 'string') {
|
|
456
|
-
|
|
457
|
-
|
|
415
|
+
// URL or path → image/video
|
|
416
|
+
if (/^(\/|\.\/|\.\.\/|https?:\/\/)/.test(raw) || /\.(jpe?g|png|webp|gif|svg|avif|mp4|webm|ogv|ogg)$/i.test(raw)) {
|
|
417
|
+
const ext = raw.split('.').pop()?.toLowerCase()
|
|
418
|
+
const isVideo = ['mp4', 'webm', 'ogv', 'ogg'].includes(ext)
|
|
419
|
+
if (isVideo) return { mode: 'video', video: { src: raw } }
|
|
420
|
+
return { mode: 'image', image: { src: raw } }
|
|
421
|
+
}
|
|
458
422
|
|
|
459
|
-
|
|
460
|
-
|
|
423
|
+
// CSS gradient function
|
|
424
|
+
if (/^(linear|radial|conic)-gradient\(/.test(raw)) {
|
|
425
|
+
return { mode: 'gradient', gradient: raw }
|
|
461
426
|
}
|
|
462
|
-
|
|
427
|
+
|
|
428
|
+
// Anything else → CSS color (hex, rgb, hsl, oklch, named color, var())
|
|
429
|
+
return { mode: 'color', color: raw }
|
|
463
430
|
}
|
|
464
431
|
|
|
465
432
|
// Object with explicit mode — pass through
|
package/src/datastore.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataStore
|
|
3
|
+
*
|
|
4
|
+
* Runtime data cache that persists across SPA navigation.
|
|
5
|
+
* Deduplicates in-flight fetches so concurrent callers share a single request.
|
|
6
|
+
*
|
|
7
|
+
* Core can't import runtime, so the fetcher function is registered at startup
|
|
8
|
+
* via registerFetcher().
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a stable cache key from a fetch config.
|
|
13
|
+
* Only includes fields that affect the response.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} config
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function cacheKey(config) {
|
|
19
|
+
const { path, url, schema, transform } = config
|
|
20
|
+
return JSON.stringify({ path, url, schema, transform })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default class DataStore {
|
|
24
|
+
constructor() {
|
|
25
|
+
this._cache = new Map()
|
|
26
|
+
this._inflight = new Map()
|
|
27
|
+
this._fetcher = null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the fetcher function (called by runtime at startup).
|
|
32
|
+
* @param {Function} fn - (config) => Promise<{ data, error? }>
|
|
33
|
+
*/
|
|
34
|
+
registerFetcher(fn) {
|
|
35
|
+
this._fetcher = fn
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check whether data for this config is cached.
|
|
40
|
+
* @param {Object} config - Fetch config
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
has(config) {
|
|
44
|
+
return this._cache.has(cacheKey(config))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Return cached data, or null on miss.
|
|
49
|
+
* @param {Object} config - Fetch config
|
|
50
|
+
* @returns {any|null}
|
|
51
|
+
*/
|
|
52
|
+
get(config) {
|
|
53
|
+
const key = cacheKey(config)
|
|
54
|
+
return this._cache.has(key) ? this._cache.get(key) : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Store data in the cache.
|
|
59
|
+
* @param {Object} config - Fetch config
|
|
60
|
+
* @param {any} data
|
|
61
|
+
*/
|
|
62
|
+
set(config, data) {
|
|
63
|
+
this._cache.set(cacheKey(config), data)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch data with caching and in-flight deduplication.
|
|
68
|
+
*
|
|
69
|
+
* - Cache hit: returns immediately.
|
|
70
|
+
* - In-flight: returns existing promise (no duplicate request).
|
|
71
|
+
* - Miss: calls the registered fetcher, caches the result.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} config - Fetch config
|
|
74
|
+
* @returns {Promise<{ data: any, error?: string }>}
|
|
75
|
+
*/
|
|
76
|
+
async fetch(config) {
|
|
77
|
+
if (!this._fetcher) {
|
|
78
|
+
throw new Error('DataStore: no fetcher registered. Call registerFetcher() first.')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const key = cacheKey(config)
|
|
82
|
+
|
|
83
|
+
// Cache hit
|
|
84
|
+
if (this._cache.has(key)) {
|
|
85
|
+
return { data: this._cache.get(key) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// In-flight dedup
|
|
89
|
+
if (this._inflight.has(key)) {
|
|
90
|
+
return this._inflight.get(key)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Miss — execute fetch
|
|
94
|
+
const promise = this._fetcher(config).then((result) => {
|
|
95
|
+
this._inflight.delete(key)
|
|
96
|
+
if (result.data !== undefined && result.data !== null) {
|
|
97
|
+
this._cache.set(key, result.data)
|
|
98
|
+
}
|
|
99
|
+
return result
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
this._inflight.set(key, promise)
|
|
103
|
+
return promise
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Flush cache and in-flight map.
|
|
108
|
+
*/
|
|
109
|
+
clear() {
|
|
110
|
+
this._cache.clear()
|
|
111
|
+
this._inflight.clear()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntityStore
|
|
3
|
+
*
|
|
4
|
+
* Resolves entity data for components by walking the page hierarchy.
|
|
5
|
+
* Leverages DataStore for caching and deduplication.
|
|
6
|
+
*
|
|
7
|
+
* Two-method API:
|
|
8
|
+
* - resolve(block, meta) — sync, reads cache only
|
|
9
|
+
* - fetch(block, meta) — async, fetches missing data via DataStore
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import singularize from './singularize.js'
|
|
13
|
+
|
|
14
|
+
export default class EntityStore {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {import('./datastore.js').default} options.dataStore
|
|
18
|
+
*/
|
|
19
|
+
constructor({ dataStore }) {
|
|
20
|
+
this.dataStore = dataStore
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Determine which schemas a component requests.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} meta - Component runtime metadata
|
|
27
|
+
* @returns {string[]|null} Array of schema names, or null if none requested
|
|
28
|
+
*/
|
|
29
|
+
_getRequestedSchemas(meta) {
|
|
30
|
+
if (!meta) return null
|
|
31
|
+
|
|
32
|
+
const inheritData = meta.inheritData
|
|
33
|
+
if (!inheritData) return null
|
|
34
|
+
|
|
35
|
+
// inheritData: true → inherit all (resolved from fetch configs)
|
|
36
|
+
// inheritData: ['articles'] → specific schemas
|
|
37
|
+
if (Array.isArray(inheritData)) return inheritData.length > 0 ? inheritData : null
|
|
38
|
+
if (inheritData === true) return [] // empty = "all available"
|
|
39
|
+
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk the hierarchy to find fetch configs for requested schemas.
|
|
45
|
+
* Order: block.fetch → page.fetch → parent.fetch → site config.fetch
|
|
46
|
+
* First match per schema wins. Only walks one parent level (auto-wiring).
|
|
47
|
+
*
|
|
48
|
+
* @param {import('./block.js').default} block
|
|
49
|
+
* @param {string[]} requested - Schema names (empty = collect all)
|
|
50
|
+
* @returns {Map<string, Object>} schema → fetch config
|
|
51
|
+
*/
|
|
52
|
+
_findFetchConfigs(block, requested) {
|
|
53
|
+
const configs = new Map()
|
|
54
|
+
const collectAll = requested.length === 0
|
|
55
|
+
|
|
56
|
+
const sources = []
|
|
57
|
+
|
|
58
|
+
// 1. Block-level fetch
|
|
59
|
+
if (block.fetch) {
|
|
60
|
+
sources.push(block.fetch)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. Page-level fetch
|
|
64
|
+
const page = block.page
|
|
65
|
+
if (page?.fetch) {
|
|
66
|
+
sources.push(page.fetch)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Parent page fetch (one level — auto-wiring for dynamic routes)
|
|
70
|
+
if (page?.parent?.fetch) {
|
|
71
|
+
sources.push(page.parent.fetch)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. Site-level fetch
|
|
75
|
+
const siteFetch = block.website?.config?.fetch
|
|
76
|
+
if (siteFetch) {
|
|
77
|
+
sources.push(siteFetch)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const source of sources) {
|
|
81
|
+
// Normalize: single config or array of configs
|
|
82
|
+
const configList = Array.isArray(source) ? source : [source]
|
|
83
|
+
|
|
84
|
+
for (const cfg of configList) {
|
|
85
|
+
if (!cfg.schema) continue
|
|
86
|
+
if (configs.has(cfg.schema)) continue // first match wins
|
|
87
|
+
|
|
88
|
+
if (collectAll || requested.includes(cfg.schema)) {
|
|
89
|
+
configs.set(cfg.schema, cfg)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return configs
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a fetch config for a single entity using the detail convention.
|
|
99
|
+
*
|
|
100
|
+
* @param {Object} collectionConfig - The collection's fetch config (must have `detail`)
|
|
101
|
+
* @param {Object} dynamicContext - { paramName, paramValue, schema }
|
|
102
|
+
* @returns {Object|null} A fetch config for the single entity, or null
|
|
103
|
+
*/
|
|
104
|
+
_buildDetailConfig(collectionConfig, dynamicContext) {
|
|
105
|
+
const { detail } = collectionConfig
|
|
106
|
+
if (!detail) return null
|
|
107
|
+
|
|
108
|
+
const { paramName, paramValue } = dynamicContext
|
|
109
|
+
if (!paramName || paramValue === undefined) return null
|
|
110
|
+
|
|
111
|
+
const baseUrl = collectionConfig.url || collectionConfig.path
|
|
112
|
+
if (!baseUrl) return null
|
|
113
|
+
|
|
114
|
+
let detailUrl
|
|
115
|
+
|
|
116
|
+
if (detail === 'rest') {
|
|
117
|
+
// REST convention: {baseUrl}/{paramValue}
|
|
118
|
+
detailUrl = `${baseUrl.replace(/\/$/, '')}/${encodeURIComponent(paramValue)}`
|
|
119
|
+
} else if (detail === 'query') {
|
|
120
|
+
// Query param convention: {baseUrl}?{paramName}={paramValue}
|
|
121
|
+
const sep = baseUrl.includes('?') ? '&' : '?'
|
|
122
|
+
detailUrl = `${baseUrl}${sep}${paramName}=${encodeURIComponent(paramValue)}`
|
|
123
|
+
} else {
|
|
124
|
+
// Custom pattern: replace {paramName} placeholders
|
|
125
|
+
detailUrl = detail.replace(/\{(\w+)\}/g, (_, key) => {
|
|
126
|
+
if (key === paramName) return encodeURIComponent(paramValue)
|
|
127
|
+
return `{${key}}` // leave unknown placeholders
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build a fetch config for the single item
|
|
132
|
+
const isLocalPath = !!collectionConfig.path && !collectionConfig.url
|
|
133
|
+
return {
|
|
134
|
+
...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
|
|
135
|
+
schema: singularize(collectionConfig.schema) || collectionConfig.schema,
|
|
136
|
+
transform: collectionConfig.transform,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve singular item for dynamic routes.
|
|
142
|
+
* If block/page has dynamicContext, find the matching item in the collection.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} data - Resolved entity data { schema: items[] }
|
|
145
|
+
* @param {Object|null} dynamicContext
|
|
146
|
+
* @returns {Object} data with singular key added if applicable
|
|
147
|
+
*/
|
|
148
|
+
_resolveSingularItem(data, dynamicContext) {
|
|
149
|
+
if (!dynamicContext) return data
|
|
150
|
+
|
|
151
|
+
const { paramName, paramValue, schema: pluralSchema } = dynamicContext
|
|
152
|
+
if (!pluralSchema || !paramName || paramValue === undefined) return data
|
|
153
|
+
|
|
154
|
+
const items = data[pluralSchema]
|
|
155
|
+
if (!Array.isArray(items)) return data
|
|
156
|
+
|
|
157
|
+
const singularSchema = singularize(pluralSchema)
|
|
158
|
+
const currentItem = items.find(
|
|
159
|
+
(item) => String(item[paramName]) === String(paramValue)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if (currentItem && singularSchema) {
|
|
163
|
+
return { ...data, [singularSchema]: currentItem }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return data
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Sync resolution. Checks DataStore cache for fetch configs found in hierarchy.
|
|
171
|
+
*
|
|
172
|
+
* @param {import('./block.js').default} block
|
|
173
|
+
* @param {Object} meta - Component runtime metadata
|
|
174
|
+
* @returns {{ status: 'ready'|'pending'|'none', data: Object|null }}
|
|
175
|
+
*/
|
|
176
|
+
resolve(block, meta) {
|
|
177
|
+
const requested = this._getRequestedSchemas(meta)
|
|
178
|
+
if (requested === null) {
|
|
179
|
+
return { status: 'none', data: null }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Walk hierarchy for fetch configs
|
|
183
|
+
const configs = this._findFetchConfigs(block, requested)
|
|
184
|
+
if (configs.size === 0) {
|
|
185
|
+
return { status: 'none', data: null }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check DataStore cache for each config
|
|
189
|
+
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
190
|
+
const data = {}
|
|
191
|
+
let allCached = true
|
|
192
|
+
|
|
193
|
+
for (const [schema, cfg] of configs) {
|
|
194
|
+
if (this.dataStore.has(cfg)) {
|
|
195
|
+
data[schema] = this.dataStore.get(cfg)
|
|
196
|
+
} else if (dynamicContext && cfg.detail) {
|
|
197
|
+
// Detail query: check if the single-entity result is cached
|
|
198
|
+
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
199
|
+
if (detailCfg && this.dataStore.has(detailCfg)) {
|
|
200
|
+
const singularKey = singularize(schema) || schema
|
|
201
|
+
data[singularKey] = this.dataStore.get(detailCfg)
|
|
202
|
+
} else {
|
|
203
|
+
allCached = false
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
allCached = false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (allCached) {
|
|
211
|
+
const resolved = this._resolveSingularItem(data, dynamicContext)
|
|
212
|
+
return { status: 'ready', data: resolved }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { status: 'pending', data: null }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Async fetch. Walks hierarchy, fetches missing data via DataStore.
|
|
220
|
+
*
|
|
221
|
+
* @param {import('./block.js').default} block
|
|
222
|
+
* @param {Object} meta - Component runtime metadata
|
|
223
|
+
* @returns {Promise<{ data: Object|null }>}
|
|
224
|
+
*/
|
|
225
|
+
async fetch(block, meta) {
|
|
226
|
+
const requested = this._getRequestedSchemas(meta)
|
|
227
|
+
if (requested === null) {
|
|
228
|
+
return { data: null }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const configs = this._findFetchConfigs(block, requested)
|
|
232
|
+
if (configs.size === 0) {
|
|
233
|
+
return { data: null }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fetch all missing configs in parallel
|
|
237
|
+
const dynamicContext = block.dynamicContext || block.page?.dynamicContext
|
|
238
|
+
const data = {}
|
|
239
|
+
const fetchPromises = []
|
|
240
|
+
|
|
241
|
+
for (const [schema, cfg] of configs) {
|
|
242
|
+
// Detail query optimization: on template pages, if the collection
|
|
243
|
+
// isn't cached and a detail convention is defined, fetch just the
|
|
244
|
+
// single entity instead of the full collection.
|
|
245
|
+
if (dynamicContext && cfg.detail && !this.dataStore.has(cfg)) {
|
|
246
|
+
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
247
|
+
if (detailCfg) {
|
|
248
|
+
fetchPromises.push(
|
|
249
|
+
this.dataStore.fetch(detailCfg).then((result) => {
|
|
250
|
+
if (result.data !== undefined && result.data !== null) {
|
|
251
|
+
const singularKey = singularize(schema) || schema
|
|
252
|
+
data[singularKey] = result.data
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
)
|
|
256
|
+
continue // skip collection fetch for this schema
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Default: fetch the full collection
|
|
261
|
+
fetchPromises.push(
|
|
262
|
+
this.dataStore.fetch(cfg).then((result) => {
|
|
263
|
+
if (result.data !== undefined && result.data !== null) {
|
|
264
|
+
data[schema] = result.data
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (fetchPromises.length > 0) {
|
|
271
|
+
await Promise.all(fetchPromises)
|
|
272
|
+
}
|
|
273
|
+
const resolved = this._resolveSingularItem(data, dynamicContext)
|
|
274
|
+
return { data: resolved }
|
|
275
|
+
}
|
|
276
|
+
}
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,8 @@ export { default as Block } from './block.js'
|
|
|
15
15
|
export { default as Input } from './input.js'
|
|
16
16
|
export { default as Analytics } from './analytics.js'
|
|
17
17
|
export { default as Theme } from './theme.js'
|
|
18
|
+
export { default as DataStore } from './datastore.js'
|
|
19
|
+
export { default as EntityStore } from './entity-store.js'
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* The singleton Uniweb instance.
|
package/src/page.js
CHANGED
|
@@ -53,6 +53,9 @@ export default class Page {
|
|
|
53
53
|
priority: pageData.seo?.priority || null,
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Parent page (set by Website.buildPageHierarchy())
|
|
57
|
+
this.parent = null
|
|
58
|
+
|
|
56
59
|
// Child pages (for nested hierarchy) - populated by Website
|
|
57
60
|
this.children = []
|
|
58
61
|
|
|
@@ -62,6 +65,10 @@ export default class Page {
|
|
|
62
65
|
// Scroll position memory (for navigation restoration)
|
|
63
66
|
this.scrollY = 0
|
|
64
67
|
|
|
68
|
+
// Fetch configuration (from page.yml data: field)
|
|
69
|
+
// Preserved at runtime so EntityStore can walk the page hierarchy
|
|
70
|
+
this.fetch = pageData.fetch || null
|
|
71
|
+
|
|
65
72
|
// Dynamic route context (for pages created from dynamic routes like /blog/:slug)
|
|
66
73
|
this.dynamicContext = pageData.dynamicContext || null
|
|
67
74
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singularize a plural schema name
|
|
3
|
+
*
|
|
4
|
+
* Converts common English plural forms to singular.
|
|
5
|
+
* Used by EntityStore to derive singular keys (e.g., 'articles' → 'article').
|
|
6
|
+
*
|
|
7
|
+
* @param {string} name - Plural name
|
|
8
|
+
* @returns {string} Singular form
|
|
9
|
+
*/
|
|
10
|
+
export default function singularize(name) {
|
|
11
|
+
if (!name) return name
|
|
12
|
+
// Common irregular plurals
|
|
13
|
+
const irregulars = {
|
|
14
|
+
people: 'person',
|
|
15
|
+
children: 'child',
|
|
16
|
+
men: 'men',
|
|
17
|
+
women: 'woman',
|
|
18
|
+
series: 'series',
|
|
19
|
+
}
|
|
20
|
+
if (irregulars[name]) return irregulars[name]
|
|
21
|
+
// -ies → -y (categories → category)
|
|
22
|
+
if (name.endsWith('ies')) return name.slice(0, -3) + 'y'
|
|
23
|
+
// -es endings that should only remove 's' (not 'es')
|
|
24
|
+
// e.g., articles → article, courses → course
|
|
25
|
+
if (name.endsWith('es')) {
|
|
26
|
+
// Check if the base word ends in a consonant that requires 'es' plural
|
|
27
|
+
// (boxes, dishes, classes, heroes) vs just 's' plural (articles, courses)
|
|
28
|
+
const base = name.slice(0, -2)
|
|
29
|
+
const lastChar = base.slice(-1)
|
|
30
|
+
// If base ends in s, x, z, ch, sh - these need 'es' for plural, so remove 'es'
|
|
31
|
+
if (['s', 'x', 'z'].includes(lastChar) || base.endsWith('ch') || base.endsWith('sh')) {
|
|
32
|
+
return base
|
|
33
|
+
}
|
|
34
|
+
// Otherwise just remove 's' (articles → article)
|
|
35
|
+
return name.slice(0, -1)
|
|
36
|
+
}
|
|
37
|
+
// Regular -s plurals
|
|
38
|
+
if (name.endsWith('s')) return name.slice(0, -1)
|
|
39
|
+
return name
|
|
40
|
+
}
|
package/src/website.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import Page from './page.js'
|
|
8
|
+
import DataStore from './datastore.js'
|
|
9
|
+
import EntityStore from './entity-store.js'
|
|
10
|
+
import singularize from './singularize.js'
|
|
8
11
|
|
|
9
12
|
export default class Website {
|
|
10
13
|
constructor(websiteData) {
|
|
@@ -79,6 +82,12 @@ export default class Website {
|
|
|
79
82
|
// Deployment base path (set by runtime via setBasePath())
|
|
80
83
|
this.basePath = ''
|
|
81
84
|
|
|
85
|
+
// Runtime data cache (fetcher registered by runtime at startup)
|
|
86
|
+
this.dataStore = new DataStore()
|
|
87
|
+
|
|
88
|
+
// Entity-aware query resolution (uses DataStore for caching)
|
|
89
|
+
this.entityStore = new EntityStore({ dataStore: this.dataStore })
|
|
90
|
+
|
|
82
91
|
// Versioned scopes: route → { versions, latestId }
|
|
83
92
|
// Scopes are routes where versioning starts (e.g., '/docs')
|
|
84
93
|
this.versionedScopes = versionedScopes
|
|
@@ -368,51 +377,44 @@ export default class Website {
|
|
|
368
377
|
singularSchema,
|
|
369
378
|
}
|
|
370
379
|
|
|
371
|
-
//
|
|
372
|
-
|
|
380
|
+
// Set dynamic context on sections so Block instances receive it
|
|
381
|
+
if (pageData.sections && Array.isArray(pageData.sections)) {
|
|
382
|
+
for (const section of pageData.sections) {
|
|
383
|
+
section.dynamicContext = pageData.dynamicContext
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Try to resolve page metadata from DataStore
|
|
388
|
+
// Look up the parent page's fetch config to find data in the store
|
|
373
389
|
const parentRoute = templatePage.route.replace(/\/:[\w]+$/, '') || '/'
|
|
374
390
|
const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
|
|
375
391
|
|
|
376
|
-
// Get items from parent's cascaded data
|
|
377
|
-
let items = []
|
|
378
|
-
let currentItem = null
|
|
379
|
-
|
|
380
392
|
if (parentPage && pluralSchema) {
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
393
|
+
// Find collection data from parent's fetch config via DataStore
|
|
394
|
+
const parentFetch = parentPage.fetch
|
|
395
|
+
let items = []
|
|
396
|
+
|
|
397
|
+
if (parentFetch) {
|
|
398
|
+
const fetchConfig = Array.isArray(parentFetch)
|
|
399
|
+
? parentFetch.find(f => f.schema === pluralSchema)
|
|
400
|
+
: (parentFetch.schema === pluralSchema ? parentFetch : null)
|
|
401
|
+
if (fetchConfig) {
|
|
402
|
+
items = this.dataStore.get(fetchConfig) || []
|
|
403
|
+
}
|
|
391
404
|
}
|
|
392
|
-
}
|
|
393
405
|
|
|
394
|
-
|
|
395
|
-
pageData.dynamicContext.currentItem = currentItem
|
|
396
|
-
pageData.dynamicContext.allItems = items
|
|
406
|
+
const currentItem = items.find(item => String(item[paramName]) === String(paramValue))
|
|
397
407
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
if (items.length > 0 && pluralSchema) {
|
|
405
|
-
cascadedData[pluralSchema] = items
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
this._injectDynamicData(pageData.sections, cascadedData, pageData.dynamicContext)
|
|
409
|
-
|
|
410
|
-
// Update page metadata from current item if available
|
|
411
|
-
if (currentItem) {
|
|
412
|
-
if (currentItem.title) pageData.title = currentItem.title
|
|
413
|
-
if (currentItem.description || currentItem.excerpt) {
|
|
414
|
-
pageData.description = currentItem.description || currentItem.excerpt
|
|
408
|
+
if (currentItem) {
|
|
409
|
+
if (currentItem.title) pageData.title = currentItem.title
|
|
410
|
+
if (currentItem.description || currentItem.excerpt) {
|
|
411
|
+
pageData.description = currentItem.description || currentItem.excerpt
|
|
412
|
+
}
|
|
415
413
|
}
|
|
414
|
+
|
|
415
|
+
// Store in dynamic context for entity resolution
|
|
416
|
+
pageData.dynamicContext.currentItem = currentItem || null
|
|
417
|
+
pageData.dynamicContext.allItems = items
|
|
416
418
|
}
|
|
417
419
|
|
|
418
420
|
// Create the page instance
|
|
@@ -437,64 +439,7 @@ export default class Website {
|
|
|
437
439
|
* @private
|
|
438
440
|
*/
|
|
439
441
|
_singularize(name) {
|
|
440
|
-
|
|
441
|
-
// Common irregular plurals
|
|
442
|
-
const irregulars = {
|
|
443
|
-
people: 'person',
|
|
444
|
-
children: 'child',
|
|
445
|
-
men: 'men',
|
|
446
|
-
women: 'woman',
|
|
447
|
-
series: 'series',
|
|
448
|
-
}
|
|
449
|
-
if (irregulars[name]) return irregulars[name]
|
|
450
|
-
// -ies → -y (categories → category)
|
|
451
|
-
if (name.endsWith('ies')) return name.slice(0, -3) + 'y'
|
|
452
|
-
// -es endings that should only remove 's' (not 'es')
|
|
453
|
-
// e.g., articles → article, courses → course
|
|
454
|
-
if (name.endsWith('es')) {
|
|
455
|
-
// Check if the base word ends in a consonant that requires 'es' plural
|
|
456
|
-
// (boxes, dishes, classes, heroes) vs just 's' plural (articles, courses)
|
|
457
|
-
const base = name.slice(0, -2)
|
|
458
|
-
const lastChar = base.slice(-1)
|
|
459
|
-
// If base ends in s, x, z, ch, sh - these need 'es' for plural, so remove 'es'
|
|
460
|
-
if (['s', 'x', 'z'].includes(lastChar) || base.endsWith('ch') || base.endsWith('sh')) {
|
|
461
|
-
return base
|
|
462
|
-
}
|
|
463
|
-
// Otherwise just remove 's' (articles → article)
|
|
464
|
-
return name.slice(0, -1)
|
|
465
|
-
}
|
|
466
|
-
// Regular -s plurals
|
|
467
|
-
if (name.endsWith('s')) return name.slice(0, -1)
|
|
468
|
-
return name
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Inject dynamic route data into sections for components with inheritData
|
|
473
|
-
* This provides both the current item (singular) and all items (plural)
|
|
474
|
-
*
|
|
475
|
-
* @private
|
|
476
|
-
* @param {Array} sections - Sections to update
|
|
477
|
-
* @param {Object} cascadedData - Data to inject { article: {...}, articles: [...] }
|
|
478
|
-
* @param {Object} dynamicContext - Dynamic route context
|
|
479
|
-
*/
|
|
480
|
-
_injectDynamicData(sections, cascadedData, dynamicContext) {
|
|
481
|
-
if (!sections || !Array.isArray(sections)) return
|
|
482
|
-
|
|
483
|
-
for (const section of sections) {
|
|
484
|
-
// Merge cascaded data into section's existing cascadedData
|
|
485
|
-
section.cascadedData = {
|
|
486
|
-
...(section.cascadedData || {}),
|
|
487
|
-
...cascadedData,
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// Also set dynamic context for Block.getDynamicContext()
|
|
491
|
-
section.dynamicContext = dynamicContext
|
|
492
|
-
|
|
493
|
-
// Recurse into subsections
|
|
494
|
-
if (section.subsections && section.subsections.length > 0) {
|
|
495
|
-
this._injectDynamicData(section.subsections, cascadedData, dynamicContext)
|
|
496
|
-
}
|
|
497
|
-
}
|
|
442
|
+
return singularize(name)
|
|
498
443
|
}
|
|
499
444
|
|
|
500
445
|
/**
|
|
@@ -659,7 +604,7 @@ export default class Website {
|
|
|
659
604
|
* @returns {string}
|
|
660
605
|
*/
|
|
661
606
|
getLocaleUrl(localeCode, route = null) {
|
|
662
|
-
let targetRoute = route || this.activePage
|
|
607
|
+
let targetRoute = route || this.activePage.route
|
|
663
608
|
|
|
664
609
|
// Strip current locale prefix if present in route
|
|
665
610
|
if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
|
|
@@ -952,7 +897,7 @@ export default class Website {
|
|
|
952
897
|
* website.getActiveRoute() // 'docs/getting-started'
|
|
953
898
|
*/
|
|
954
899
|
getActiveRoute() {
|
|
955
|
-
return this.activePage
|
|
900
|
+
return this.activePage.getNormalizedRoute()
|
|
956
901
|
}
|
|
957
902
|
|
|
958
903
|
/**
|