@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.3.9",
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.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,38 @@ 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
+
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
- // Cascaded data from page/site level fetches
60
- // Populated during render for components with inheritData
61
- 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
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. Simple key-value content (PoC style)
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
- // Simple key-value content (PoC style) - pass through directly
101
- // 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.
102
130
  if (content && typeof content === 'object' && !Array.isArray(content)) {
103
- // Mark as PoC format so runtime can detect and pass through
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
- main: { header: {}, body: {} },
113
- items: []
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
- * Get the current item from cascaded data using dynamic route params
375
- * Looks up the item in cascadedData that matches the URL param value
401
+ * Normalize a background value from section frontmatter
376
402
  *
377
- * @param {string} [schema] - Schema name to look up (e.g., 'articles'). If omitted, uses parentSchema from dynamicContext.
378
- * @returns {Object|null} The matched item, or null if not found
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
- * @example
381
- * // URL: /blog/my-post, cascadedData: { articles: [{slug: 'my-post', title: 'My Post'}, ...] }
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
- getCurrentItem(schema) {
386
- const ctx = this.dynamicContext
387
- if (!ctx) return null
388
-
389
- const { paramName, paramValue } = ctx
390
-
391
- // If schema not provided, try to infer from cascadedData keys
392
- const lookupSchema = schema || this._inferSchema()
393
- if (!lookupSchema) return null
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
- // Find item where the param field matches the URL value
399
- return items.find(item => String(item[paramName]) === String(paramValue)) || null
400
- }
423
+ // CSS gradient function
424
+ if (/^(linear|radial|conic)-gradient\(/.test(raw)) {
425
+ return { mode: 'gradient', gradient: raw }
426
+ }
401
427
 
402
- /**
403
- * Get all items from cascaded data for the dynamic route's schema
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
- const items = this.cascadedData[lookupSchema]
413
- return Array.isArray(items) ? items : []
414
- }
432
+ // Object with explicit mode — pass through
433
+ if (raw.mode) return raw
415
434
 
416
- /**
417
- * Infer the schema name from cascaded data keys
418
- * Looks for the first array in cascadedData
419
- * @private
420
- */
421
- _inferSchema() {
422
- for (const key of Object.keys(this.cascadedData)) {
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 null
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
  /**
@@ -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
  /**