@uniweb/core 0.3.9 → 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 +82 -60
- 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,38 @@ 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
|
+
|
|
67
|
+
// Extract background from params into standardOptions
|
|
68
|
+
// Content authors set background in section frontmatter; the runtime
|
|
69
|
+
// reads it from standardOptions to render the Background component.
|
|
70
|
+
const rawBg = blockConfig.background
|
|
71
|
+
if (rawBg && !this.standardOptions.background) {
|
|
72
|
+
this.standardOptions = {
|
|
73
|
+
...this.standardOptions,
|
|
74
|
+
background: Block.normalizeBackground(rawBg)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
// Child blocks (subsections)
|
|
51
79
|
this.childBlocks = blockData.subsections
|
|
52
80
|
? blockData.subsections.map((block, i) => new Block(block, `${id}_${i}`))
|
|
@@ -56,9 +84,9 @@ export default class Block {
|
|
|
56
84
|
// Supports local files (path) or remote URLs (url)
|
|
57
85
|
this.fetch = blockData.fetch || null
|
|
58
86
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
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
|
|
62
90
|
|
|
63
91
|
// Dynamic route context (params from URL matching)
|
|
64
92
|
// Set when accessing a dynamic page like /blog/:slug -> /blog/my-post
|
|
@@ -78,7 +106,7 @@ export default class Block {
|
|
|
78
106
|
* Supports multiple content formats:
|
|
79
107
|
* 1. Pre-parsed groups structure (from editor)
|
|
80
108
|
* 2. ProseMirror document (from markdown collection)
|
|
81
|
-
* 3.
|
|
109
|
+
* 3. Plain object (passed through directly)
|
|
82
110
|
*
|
|
83
111
|
* Uses @uniweb/semantic-parser for rich content extraction including:
|
|
84
112
|
* - Pretitle detection (H3 before H1)
|
|
@@ -97,20 +125,18 @@ export default class Block {
|
|
|
97
125
|
return this.extractFromProseMirror(content)
|
|
98
126
|
}
|
|
99
127
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
128
|
+
// Plain object content — pass through directly.
|
|
129
|
+
// guaranteeContentStructure() in prepare-props will fill in missing fields.
|
|
102
130
|
if (content && typeof content === 'object' && !Array.isArray(content)) {
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
_isPoc: true,
|
|
106
|
-
_pocContent: content
|
|
107
|
-
}
|
|
131
|
+
return content
|
|
108
132
|
}
|
|
109
133
|
|
|
110
|
-
// Fallback
|
|
134
|
+
// Fallback — empty flat structure
|
|
111
135
|
return {
|
|
112
|
-
|
|
113
|
-
|
|
136
|
+
title: '',
|
|
137
|
+
paragraphs: [],
|
|
138
|
+
items: [],
|
|
139
|
+
sequence: []
|
|
114
140
|
}
|
|
115
141
|
}
|
|
116
142
|
|
|
@@ -304,6 +330,7 @@ export default class Block {
|
|
|
304
330
|
return {
|
|
305
331
|
type: this.type,
|
|
306
332
|
theme: this.themeName,
|
|
333
|
+
contextOverrides: this.contextOverrides,
|
|
307
334
|
state: this.state,
|
|
308
335
|
context: this.context
|
|
309
336
|
}
|
|
@@ -371,60 +398,55 @@ export default class Block {
|
|
|
371
398
|
}
|
|
372
399
|
|
|
373
400
|
/**
|
|
374
|
-
*
|
|
375
|
-
* Looks up the item in cascadedData that matches the URL param value
|
|
401
|
+
* Normalize a background value from section frontmatter
|
|
376
402
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
403
|
+
* Accepts:
|
|
404
|
+
* - String URL: "/images/hero.jpg" → { mode: 'image', image: { src } }
|
|
405
|
+
* - String URL (video): "/videos/bg.mp4" → { mode: 'video', video: { src } }
|
|
406
|
+
* - Object with mode: passed through as-is
|
|
407
|
+
* - Object without mode: mode inferred from which fields are present
|
|
379
408
|
*
|
|
380
|
-
* @
|
|
381
|
-
*
|
|
382
|
-
* block.getCurrentItem('articles')
|
|
383
|
-
* // { slug: 'my-post', title: 'My Post', ... }
|
|
409
|
+
* @param {string|Object} raw - Raw background value from frontmatter
|
|
410
|
+
* @returns {Object} Normalized background config with mode
|
|
384
411
|
*/
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const items = this.cascadedData[lookupSchema]
|
|
396
|
-
if (!Array.isArray(items)) return null
|
|
412
|
+
static normalizeBackground(raw) {
|
|
413
|
+
// String shorthand — classify by content
|
|
414
|
+
if (typeof raw === 'string') {
|
|
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
|
+
}
|
|
397
422
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
423
|
+
// CSS gradient function
|
|
424
|
+
if (/^(linear|radial|conic)-gradient\(/.test(raw)) {
|
|
425
|
+
return { mode: 'gradient', gradient: raw }
|
|
426
|
+
}
|
|
401
427
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
* @param {string} [schema] - Schema name to look up. If omitted, uses parentSchema from dynamicContext.
|
|
406
|
-
* @returns {Array} Array of items, or empty array if not found
|
|
407
|
-
*/
|
|
408
|
-
getAllItems(schema) {
|
|
409
|
-
const lookupSchema = schema || this._inferSchema()
|
|
410
|
-
if (!lookupSchema) return []
|
|
428
|
+
// Anything else → CSS color (hex, rgb, hsl, oklch, named color, var())
|
|
429
|
+
return { mode: 'color', color: raw }
|
|
430
|
+
}
|
|
411
431
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
432
|
+
// Object with explicit mode — pass through
|
|
433
|
+
if (raw.mode) return raw
|
|
415
434
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (Array.isArray(this.cascadedData[key])) {
|
|
424
|
-
return key
|
|
435
|
+
// Infer mode from fields
|
|
436
|
+
if (raw.video || raw.sources) return { mode: 'video', ...raw }
|
|
437
|
+
if (raw.image || raw.src) {
|
|
438
|
+
// Support flat { src, position, size } shorthand
|
|
439
|
+
if (raw.src) {
|
|
440
|
+
const { src, position, size, lazy, ...rest } = raw
|
|
441
|
+
return { mode: 'image', image: { src, position, size, lazy }, ...rest }
|
|
425
442
|
}
|
|
443
|
+
return { mode: 'image', ...raw }
|
|
426
444
|
}
|
|
427
|
-
return
|
|
445
|
+
if (raw.gradient) return { mode: 'gradient', ...raw }
|
|
446
|
+
if (raw.color) return { mode: 'color', ...raw }
|
|
447
|
+
|
|
448
|
+
// Can't infer — return as-is (BlockRenderer checks for mode)
|
|
449
|
+
return raw
|
|
428
450
|
}
|
|
429
451
|
|
|
430
452
|
/**
|
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
|
/**
|