@uniweb/core 0.3.10 → 0.4.1

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.3.10",
3
+ "version": "0.4.1",
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.17"
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
- this.themeName = blockConfig.theme || 'light'
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
- // Cascaded data from page/site level fetches
71
- // Populated during render for components with inheritData
72
- this.cascadedData = blockData.cascadedData || {}
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. Simple key-value content (PoC style)
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
- // Simple key-value content (PoC style) - pass through directly
112
- // This allows components to receive content like { title, subtitle, items }
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
- // Mark as PoC format so runtime can detect and pass through
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
- main: { header: {}, body: {} },
124
- items: []
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,20 +410,33 @@ export default class Block {
451
410
  * @returns {Object} Normalized background config with mode
452
411
  */
453
412
  static normalizeBackground(raw) {
454
- // String URL shorthand
413
+ // String shorthand — classify by content
455
414
  if (typeof raw === 'string') {
456
- const ext = raw.split('.').pop()?.toLowerCase()
457
- const isVideo = ['mp4', 'webm', 'ogv', 'ogg'].includes(ext)
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
- if (isVideo) {
460
- return { mode: 'video', video: { src: raw } }
423
+ // CSS gradient function
424
+ if (/^(linear|radial|conic)-gradient\(/.test(raw)) {
425
+ return { mode: 'gradient', gradient: raw }
461
426
  }
462
- return { mode: 'image', image: { src: raw } }
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
466
433
  if (raw.mode) return raw
467
434
 
435
+ // Normalize overlay shorthand: number → { enabled: true, type: 'dark', opacity }
436
+ if (typeof raw.overlay === 'number') {
437
+ raw = { ...raw, overlay: { enabled: true, type: 'dark', opacity: raw.overlay } }
438
+ }
439
+
468
440
  // Infer mode from fields
469
441
  if (raw.video || raw.sources) return { mode: 'video', ...raw }
470
442
  if (raw.image || raw.src) {
@@ -473,6 +445,11 @@ export default class Block {
473
445
  const { src, position, size, lazy, ...rest } = raw
474
446
  return { mode: 'image', image: { src, position, size, lazy }, ...rest }
475
447
  }
448
+ // Support string shorthand: { image: "url" } → { image: { src: "url" } }
449
+ if (typeof raw.image === 'string') {
450
+ const { image, ...rest } = raw
451
+ return { mode: 'image', image: { src: image }, ...rest }
452
+ }
476
453
  return { mode: 'image', ...raw }
477
454
  }
478
455
  if (raw.gradient) return { mode: 'gradient', ...raw }
@@ -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
- // Get the parent page's data to find the items array
372
- // Parent route is the template route without the :param suffix
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
- // Get items from parent page's first section's cascadedData
382
- // This is where the page-level fetch stores its data
383
- const firstSection = parentPage.pageBlocks?.body?.[0]
384
- if (firstSection) {
385
- items = firstSection.cascadedData?.[pluralSchema] || []
386
- }
387
-
388
- // Find the current item using the param
389
- if (items.length > 0) {
390
- currentItem = items.find(item => String(item[paramName]) === String(paramValue))
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
- // Store items in dynamic context for Block.getCurrentItem() / getAllItems()
395
- pageData.dynamicContext.currentItem = currentItem
396
- pageData.dynamicContext.allItems = items
406
+ const currentItem = items.find(item => String(item[paramName]) === String(paramValue))
397
407
 
398
- // Inject cascaded data into sections for components with inheritData
399
- // This provides both singular (article) and plural (articles) data
400
- const cascadedData = {}
401
- if (currentItem && singularSchema) {
402
- cascadedData[singularSchema] = currentItem
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
- if (!name) return name
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?.route || '/'
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?.getNormalizedRoute() || ''
900
+ return this.activePage.getNormalizedRoute()
956
901
  }
957
902
 
958
903
  /**