@uniweb/core 0.7.10 → 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 +3 -4
- package/package.json +3 -3
- package/src/entity-store.js +47 -59
- package/src/fetcher-dispatcher.js +7 -1
- package/src/index.js +0 -1
- package/src/page.js +102 -10
- package/src/website.js +3 -13
- package/src/where.js +0 -2
- package/src/singularize.js +0 -40
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.
|
|
133
|
-
page.
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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": "
|
|
37
|
+
"test": "vitest run"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
331
|
-
|
|
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 (
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
351
|
-
const match = Array.isArray(
|
|
352
|
-
?
|
|
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[
|
|
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[
|
|
342
|
+
data[schema] = [detailCached.data]
|
|
362
343
|
} else if (detailCfg) {
|
|
363
344
|
allCached = false
|
|
364
345
|
} else {
|
|
365
|
-
data[
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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 (
|
|
436
|
-
//
|
|
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[
|
|
435
|
+
data[schema] = []
|
|
452
436
|
continue
|
|
453
437
|
}
|
|
454
438
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
33
|
-
this.
|
|
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
|
|
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
|
|
418
|
+
return this.showInNav('footer')
|
|
385
419
|
}
|
|
386
420
|
|
|
387
421
|
/**
|
|
@@ -426,11 +460,34 @@ export default class Page {
|
|
|
426
460
|
this._loadingContent = (async () => {
|
|
427
461
|
try {
|
|
428
462
|
const base = this.website.basePath || ''
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
463
|
+
const lang = this.website.activeLocale
|
|
464
|
+
const defaultLang = this.website.defaultLocale
|
|
465
|
+
|
|
466
|
+
// Phase 5 of the CDN migration: prefer the content-addressed URL from
|
|
467
|
+
// __DATA__.config.pageHashes (the publish handler emits it). Falls
|
|
468
|
+
// back to the legacy /{lang}/_pages/{route}.json shape when the map
|
|
469
|
+
// isn't present (older sites that haven't republished yet).
|
|
470
|
+
const hashes = this.website.config?.pageHashes
|
|
471
|
+
const hashedUrl = hashes?.[lang]?.[this.route]
|
|
472
|
+
|
|
473
|
+
let url
|
|
474
|
+
if (hashedUrl) {
|
|
475
|
+
url = `${base}${hashedUrl}`
|
|
476
|
+
} else {
|
|
477
|
+
const localePrefix = lang !== defaultLang ? `/${lang}` : ''
|
|
478
|
+
const routePath = this.route === '/' ? '/index' : this.route
|
|
479
|
+
url = `${base}${localePrefix}/_pages${routePath}.json`
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const res = await fetch(url)
|
|
483
|
+
if (res.status === 404 && _shouldReloadOnHashed404(hashedUrl)) {
|
|
484
|
+
// Stale tab: this tab's __DATA__ map references a publish that
|
|
485
|
+
// no longer exists in R2. One reload usually picks up the fresh
|
|
486
|
+
// HTML (and a fresh map). Capped at 2 attempts to defend against
|
|
487
|
+
// a malformed publish where new HTML and new R2 also disagree.
|
|
488
|
+
window.location.reload()
|
|
489
|
+
return
|
|
490
|
+
}
|
|
434
491
|
if (!res.ok) {
|
|
435
492
|
console.warn(`[Page] Failed to load content for ${this.route}: ${res.status}`)
|
|
436
493
|
this._bodySections = [] // Mark as loaded (empty) to prevent retries
|
|
@@ -439,6 +496,11 @@ export default class Page {
|
|
|
439
496
|
const data = await res.json()
|
|
440
497
|
this._bodySections = data.sections || []
|
|
441
498
|
this._bodyBlocks = null // Reset lazy cache so getter rebuilds
|
|
499
|
+
// Reset the reload counter on a successful fetch so future stale
|
|
500
|
+
// tabs (across many publishes in one session) get fresh attempts.
|
|
501
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
502
|
+
sessionStorage.removeItem('uniwebReloadCount')
|
|
503
|
+
}
|
|
442
504
|
} finally {
|
|
443
505
|
this._loadingContent = null
|
|
444
506
|
}
|
|
@@ -609,3 +671,33 @@ export default class Page {
|
|
|
609
671
|
return this.website.getVersionUrl(targetVersion, this.route)
|
|
610
672
|
}
|
|
611
673
|
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Helper for the stale-tab 404→reload guard in Page.loadContent.
|
|
677
|
+
*
|
|
678
|
+
* Phase 5 of the CDN migration writes content-addressed `_pages/...-{hash}.json`
|
|
679
|
+
* files. Each publish wipes the prior hashes from R2 and writes fresh ones.
|
|
680
|
+
* A browser tab loaded against a previous publish carries a stale __DATA__
|
|
681
|
+
* map; on SPA navigation it'll request a hash that no longer exists.
|
|
682
|
+
* Reloading the tab pulls fresh HTML (and a fresh map). The session-scoped
|
|
683
|
+
* counter caps reloads at 2 so a malformed publish (where new HTML and new
|
|
684
|
+
* R2 disagree) doesn't trigger an infinite loop — after 2 failed reloads
|
|
685
|
+
* we render the synthetic 404 path instead.
|
|
686
|
+
*
|
|
687
|
+
* Only fires when the request was a hashed URL: legacy non-hashed 404s
|
|
688
|
+
* keep the prior "mark empty, log warning" behavior (they're not symptomatic
|
|
689
|
+
* of a stale-tab race; just a missing route).
|
|
690
|
+
*/
|
|
691
|
+
function _shouldReloadOnHashed404(hashedUrl) {
|
|
692
|
+
if (!hashedUrl) return false
|
|
693
|
+
if (typeof sessionStorage === 'undefined') return false
|
|
694
|
+
if (typeof window === 'undefined' || !window.location) return false
|
|
695
|
+
try {
|
|
696
|
+
const count = parseInt(sessionStorage.getItem('uniwebReloadCount') || '0', 10) || 0
|
|
697
|
+
if (count >= 2) return false
|
|
698
|
+
sessionStorage.setItem('uniwebReloadCount', String(count + 1))
|
|
699
|
+
return true
|
|
700
|
+
} catch {
|
|
701
|
+
return false
|
|
702
|
+
}
|
|
703
|
+
}
|
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
|
-
|
|
1082
|
-
|
|
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
package/src/singularize.js
DELETED
|
@@ -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
|
-
}
|