@uniweb/core 0.7.11 → 0.7.12

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/README.md CHANGED
@@ -129,8 +129,8 @@ page.website // Back-reference to parent Website
129
129
 
130
130
  // Navigation visibility
131
131
  page.hidden // Hidden from all navigation
132
- page.hideInHeader // Hidden from header nav only
133
- page.hideInFooter // Hidden from footer nav only
132
+ page.hideIn // Nav areas this page is hidden from (e.g. ['header'])
133
+ page.showInNav(area) // Should appear in a given named nav area?
134
134
  page.isHidden() // Check if hidden from navigation
135
135
  page.showInHeader() // Should appear in header nav?
136
136
  page.showInFooter() // Should appear in footer nav?
@@ -165,8 +165,7 @@ order: 2
165
165
 
166
166
  # Navigation visibility
167
167
  hidden: true # Hide from all navigation
168
- hideInHeader: true # Hide from header nav only
169
- hideInFooter: true # Hide from footer nav only
168
+ hideIn: [header] # Hide from named nav areas only (header, footer, sidebar, …)
170
169
 
171
170
  # Layout overrides (default: all true)
172
171
  layout:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.7.11",
3
+ "version": "0.7.12",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,13 +27,13 @@
27
27
  "node": ">=20.19"
28
28
  },
29
29
  "devDependencies": {
30
- "jest": "^29.7.0"
30
+ "vitest": "^4.1.7"
31
31
  },
32
32
  "dependencies": {
33
33
  "@uniweb/semantic-parser": "1.1.17",
34
34
  "@uniweb/theming": "0.1.3"
35
35
  },
36
36
  "scripts": {
37
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
37
+ "test": "vitest run"
38
38
  }
39
39
  }
@@ -14,7 +14,6 @@
14
14
  * and in-flight dedup.
15
15
  */
16
16
 
17
- import singularize from './singularize.js'
18
17
  import { substitutePlaceholders } from './substitute-placeholders.js'
19
18
 
20
19
  /**
@@ -220,7 +219,7 @@ export default class EntityStore {
220
219
  if (detail && typeof detail === 'object') {
221
220
  const out = {
222
221
  ...(isLocalPath ? { path: baseUrl } : { url: baseUrl }),
223
- schema: singularize(collectionConfig.schema) || collectionConfig.schema,
222
+ schema: collectionConfig.schema,
224
223
  transform: collectionConfig.transform,
225
224
  }
226
225
  if (collectionConfig.method) out.method = collectionConfig.method
@@ -253,32 +252,11 @@ export default class EntityStore {
253
252
 
254
253
  return {
255
254
  ...(isLocalPath ? { path: detailUrl } : { url: detailUrl }),
256
- schema: singularize(collectionConfig.schema) || collectionConfig.schema,
255
+ schema: collectionConfig.schema,
257
256
  transform: collectionConfig.transform,
258
257
  }
259
258
  }
260
259
 
261
- /**
262
- * For dynamic routes: extract the matching item from a collection and
263
- * expose it under the singular schema key (articles → article).
264
- */
265
- _resolveSingularItem(data, dynamicContext) {
266
- if (!dynamicContext) return data
267
- const { paramName, paramValue, schema: pluralSchema } = dynamicContext
268
- if (!pluralSchema || !paramName || paramValue === undefined) return data
269
- const items = data[pluralSchema]
270
- if (!Array.isArray(items)) return data
271
-
272
- const singularSchema = singularize(pluralSchema)
273
- const currentItem = items.find(
274
- (item) => String(item[paramName]) === String(paramValue)
275
- )
276
- if (currentItem && singularSchema) {
277
- return { ...data, [singularSchema]: currentItem }
278
- }
279
- return data
280
- }
281
-
282
260
  /**
283
261
  * Build the `ctx` handed to the dispatcher for a given block.
284
262
  * @private
@@ -326,9 +304,12 @@ export default class EntityStore {
326
304
  const data = {}
327
305
  let allCached = true
328
306
 
307
+ const routeSchema = dynamicContext?.schema
308
+
329
309
  for (const [schema, cfg] of configs) {
330
- if (dynamicContext && cfg.detail && !inheritDetail) {
331
- // detail: false return collection minus the active item.
310
+ const isRouteCollection = dynamicContext && schema === routeSchema
311
+ if (isRouteCollection && !inheritDetail) {
312
+ // refine detail:false — the collection minus the active item (related items).
332
313
  const cached = dispatcher?.peek(cfg, ctx)
333
314
  if (cached) {
334
315
  const { paramName, paramValue } = dynamicContext
@@ -341,29 +322,31 @@ export default class EntityStore {
341
322
  } else {
342
323
  allCached = false
343
324
  }
344
- } else if (dynamicContext && cfg.detail) {
345
- // Collection-first detail: the collection is the content gate.
346
- const cachedCollection = dispatcher?.peek(cfg, ctx)
347
- if (cachedCollection) {
348
- const collectionItems = cachedCollection.data
325
+ } else if (isRouteCollection) {
326
+ // Detail page: deliver the focused record as a length-1 array under the
327
+ // collection key. Deferred/API collections fetch the full per-record;
328
+ // others use the matched item from the collection. Not found → [].
329
+ const cached = dispatcher?.peek(cfg, ctx)
330
+ if (cached) {
349
331
  const { paramName, paramValue } = dynamicContext
350
- const singularKey = singularize(schema) || schema
351
- const match = Array.isArray(collectionItems)
352
- ? collectionItems.find((item) => String(item[paramName]) === String(paramValue))
332
+ const items = cached.data
333
+ const match = Array.isArray(items)
334
+ ? items.find((item) => String(item[paramName]) === String(paramValue))
353
335
  : null
354
-
355
336
  if (!match) {
356
- data[singularKey] = null
357
- } else {
337
+ data[schema] = []
338
+ } else if (cfg.detail) {
358
339
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
359
340
  const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
360
341
  if (detailCfg && detailCached) {
361
- data[singularKey] = detailCached.data
342
+ data[schema] = [detailCached.data]
362
343
  } else if (detailCfg) {
363
344
  allCached = false
364
345
  } else {
365
- data[singularKey] = match
346
+ data[schema] = [match]
366
347
  }
348
+ } else {
349
+ data[schema] = [match]
367
350
  }
368
351
  } else {
369
352
  allCached = false
@@ -380,8 +363,7 @@ export default class EntityStore {
380
363
  }
381
364
 
382
365
  if (allCached) {
383
- const resolved = this._resolveSingularItem(data, dynamicContext)
384
- return { status: 'ready', data: resolved }
366
+ return { status: 'ready', data }
385
367
  }
386
368
  return { status: 'pending', data: null }
387
369
  }
@@ -418,9 +400,12 @@ export default class EntityStore {
418
400
  const data = {}
419
401
  const parallelFetches = []
420
402
 
403
+ const routeSchema = dynamicContext?.schema
404
+
421
405
  for (const [schema, cfg] of configs) {
422
- if (dynamicContext && cfg.detail && !inheritDetail) {
423
- // detail: false collection-only, minus the active item.
406
+ const isRouteCollection = dynamicContext && schema === routeSchema
407
+ if (isRouteCollection && !inheritDetail) {
408
+ // refine detail:false — the collection minus the active item.
424
409
  let collectionItems = peekArray(dispatcher, cfg, ctx)
425
410
  if (collectionItems === null) {
426
411
  const result = await dispatcher.dispatch(cfg, ctx)
@@ -432,10 +417,9 @@ export default class EntityStore {
432
417
  : (collectionItems ?? [])
433
418
  if (order) filtered = this._sortItems(filtered, order)
434
419
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
435
- } else if (dynamicContext && cfg.detail) {
436
- // Collection-first detail resolution.
420
+ } else if (isRouteCollection) {
421
+ // Detail page: focused record as a length-1 array under the collection key.
437
422
  const { paramName, paramValue } = dynamicContext
438
- const singularKey = singularize(schema) || schema
439
423
 
440
424
  let collectionItems = peekArray(dispatcher, cfg, ctx)
441
425
  if (collectionItems === null) {
@@ -448,21 +432,26 @@ export default class EntityStore {
448
432
  ) ?? null
449
433
 
450
434
  if (!match) {
451
- data[singularKey] = null
435
+ data[schema] = []
452
436
  continue
453
437
  }
454
438
 
455
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
456
- if (detailCfg) {
457
- parallelFetches.push(
458
- dispatcher.dispatch(detailCfg, ctx).then((result) => {
459
- data[singularKey] = (result?.data !== undefined && result?.data !== null)
460
- ? result.data
461
- : match
462
- })
463
- )
439
+ if (cfg.detail) {
440
+ const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
441
+ if (detailCfg) {
442
+ parallelFetches.push(
443
+ dispatcher.dispatch(detailCfg, ctx).then((result) => {
444
+ const record = (result?.data !== undefined && result?.data !== null)
445
+ ? result.data
446
+ : match
447
+ data[schema] = [record]
448
+ })
449
+ )
450
+ } else {
451
+ data[schema] = [match]
452
+ }
464
453
  } else {
465
- data[singularKey] = match
454
+ data[schema] = [match]
466
455
  }
467
456
  } else {
468
457
  parallelFetches.push(
@@ -476,8 +465,7 @@ export default class EntityStore {
476
465
  }
477
466
 
478
467
  if (parallelFetches.length > 0) await Promise.all(parallelFetches)
479
- const resolved = this._resolveSingularItem(data, dynamicContext)
480
- return { data: resolved }
468
+ return { data }
481
469
  }
482
470
  }
483
471
 
@@ -44,7 +44,13 @@ function collectTransports(decl, { source, dev }) {
44
44
 
45
45
  let raw
46
46
  try {
47
- raw = decl.transports
47
+ // A built foundation carries its declarations under `default.capabilities`
48
+ // — framework/build/src/generate-entry.js spreads the source `main.js`
49
+ // default (props, transports, …) into `capabilities`, and `decl` here is
50
+ // that built `default` (= { meta, capabilities, layoutMeta }). Transports
51
+ // therefore live at `default.capabilities.transports`: a single direct
52
+ // read, no fallback chain (the runtime only ever sees the built shape).
53
+ raw = decl.capabilities?.transports
48
54
  } catch (err) {
49
55
  if (dev) console.warn(`[FetcherDispatcher] ${source} transports getter threw:`, err)
50
56
  return out
package/src/index.js CHANGED
@@ -19,7 +19,6 @@ export { default as FetcherDispatcher } from './fetcher-dispatcher.js'
19
19
  export { default as ObservableState } from './observable-state.js'
20
20
 
21
21
  // Utilities
22
- export { default as singularize } from './singularize.js'
23
22
  export { substitutePlaceholders } from './substitute-placeholders.js'
24
23
  export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
25
24
  export { isRichSchema } from './schemas.js'
package/src/page.js CHANGED
@@ -8,6 +8,26 @@
8
8
  import Block from './block.js'
9
9
  import ObservableState from './observable-state.js'
10
10
 
11
+ // Normalize a page's nav-visibility into a deduped hideIn array (the layout-area
12
+ // names the page is suppressed from). Reads the canonical hideIn (array, or a single
13
+ // string) and folds in the legacy hideInHeader/hideInFooter booleans for back-compat.
14
+ function normalizeHideIn(pageData) {
15
+ const out = []
16
+ const seen = new Set()
17
+ const add = (a) => {
18
+ if (typeof a === 'string' && a && !seen.has(a)) {
19
+ seen.add(a)
20
+ out.push(a)
21
+ }
22
+ }
23
+ const raw = pageData.hideIn
24
+ if (Array.isArray(raw)) raw.forEach(add)
25
+ else add(raw)
26
+ if (pageData.hideInHeader) add('header')
27
+ if (pageData.hideInFooter) add('footer')
28
+ return out
29
+ }
30
+
11
31
  export default class Page {
12
32
  constructor(pageData, id, website) {
13
33
  this.id = id
@@ -27,10 +47,14 @@ export default class Page {
27
47
  // Rewrite target (if set, this route is served by an external site)
28
48
  this.rewrite = pageData.rewrite || null
29
49
 
30
- // Navigation visibility options
50
+ // Navigation visibility. `hidden` excludes the page from all nav; `hideIn` is a
51
+ // per-area denylist (layout-area names, e.g. ['header','footer']). The legacy
52
+ // hideInHeader/hideInFooter booleans are folded into hideIn and kept as derived
53
+ // accessors for back-compat.
31
54
  this.hidden = pageData.hidden || false
32
- this.hideInHeader = pageData.hideInHeader || false
33
- this.hideInFooter = pageData.hideInFooter || false
55
+ this.hideIn = normalizeHideIn(pageData)
56
+ this.hideInHeader = this.hideIn.includes('header')
57
+ this.hideInFooter = this.hideIn.includes('footer')
34
58
 
35
59
  // Layout options (named layout + per-page overrides)
36
60
  this.layout = {
@@ -368,12 +392,22 @@ export default class Page {
368
392
  return this.hidden
369
393
  }
370
394
 
395
+ /**
396
+ * Check if page should appear in a named nav area ('header', 'footer', or any
397
+ * foundation-declared area). The general form behind showInHeader/showInFooter.
398
+ * @param {string} area
399
+ * @returns {boolean}
400
+ */
401
+ showInNav(area) {
402
+ return !this.hidden && !this.hideIn.includes(area)
403
+ }
404
+
371
405
  /**
372
406
  * Check if page should appear in header navigation
373
407
  * @returns {boolean}
374
408
  */
375
409
  showInHeader() {
376
- return !this.hidden && !this.hideInHeader
410
+ return this.showInNav('header')
377
411
  }
378
412
 
379
413
  /**
@@ -381,7 +415,7 @@ export default class Page {
381
415
  * @returns {boolean}
382
416
  */
383
417
  showInFooter() {
384
- return !this.hidden && !this.hideInFooter
418
+ return this.showInNav('footer')
385
419
  }
386
420
 
387
421
  /**
package/src/website.js CHANGED
@@ -9,7 +9,6 @@ import DataStore from './datastore.js'
9
9
  import EntityStore from './entity-store.js'
10
10
  import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
- import singularize from './singularize.js'
13
12
 
14
13
  /**
15
14
  * Website — orchestration root for a single site instance.
@@ -536,7 +535,6 @@ export default class Website {
536
535
  const paramName = Object.keys(params)[0]
537
536
  const paramValue = Object.values(params)[0]
538
537
  const pluralSchema = originalData.parentSchema // e.g., 'articles'
539
- const singularSchema = this._singularize(pluralSchema) // e.g., 'article'
540
538
 
541
539
  // Store dynamic context for components to access
542
540
  pageData.dynamicContext = {
@@ -545,7 +543,6 @@ export default class Website {
545
543
  paramName,
546
544
  paramValue,
547
545
  schema: pluralSchema,
548
- singularSchema,
549
546
  }
550
547
 
551
548
  // Set dynamic context on sections so Block instances receive it
@@ -607,14 +604,6 @@ export default class Website {
607
604
  return { page: dynamicPage, collectionLoaded: pageData._collectionLoaded ?? true }
608
605
  }
609
606
 
610
- /**
611
- * Singularize a plural schema name
612
- * @private
613
- */
614
- _singularize(name) {
615
- return singularize(name)
616
- }
617
-
618
607
  /**
619
608
  * Set active page by route
620
609
  * @param {string} route
@@ -1078,8 +1067,9 @@ export default class Website {
1078
1067
  // Check visibility based on navigation type
1079
1068
  if (!includeHidden) {
1080
1069
  if (page.hidden) return false
1081
- if (navType === 'header' && page.hideInHeader) return false
1082
- if (navType === 'footer' && page.hideInFooter) return false
1070
+ // navType is the requested nav area ('header'/'footer'/any foundation area);
1071
+ // hideIn lists the areas this page is suppressed from.
1072
+ if (navType && page.hideIn?.includes(navType)) return false
1083
1073
  }
1084
1074
 
1085
1075
  // Skip content-less containers that have no visible or navigable children.
package/src/where.js CHANGED
@@ -7,8 +7,6 @@
7
7
  * (which translate to their native query language) or to this evaluator
8
8
  * (which walks the object against a record).
9
9
  *
10
- * Architecture: see kb/framework/architecture/data-fetching.md.
11
- *
12
10
  * Shape:
13
11
  *
14
12
  * {
@@ -1,40 +0,0 @@
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
- }