@uniweb/core 0.7.13 → 0.7.15

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.7.13",
3
+ "version": "0.7.15",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
package/src/block.js CHANGED
@@ -437,6 +437,15 @@ export default class Block {
437
437
  * @returns {Object|null} Next block's info or null
438
438
  */
439
439
  getNextBlockInfo() {
440
+ // Layout-area blocks (header/footer/panels) live on a shared, contentless
441
+ // area page — not the content page being rendered — so walking their own
442
+ // page's sequence never reaches the content. Their "next block" is the
443
+ // first content section of the active page (a header adapting to the
444
+ // section it floats over). Resolve against the active page instead.
445
+ const active = this.website?.activePage
446
+ if (active && active !== this.page) {
447
+ return active.getFirstBodyBlockInfo()
448
+ }
440
449
  const index = this.getIndex()
441
450
  if (index < 0 || !this.page) return null
442
451
  return this.page.getBlockInfo(index + 1)
@@ -448,6 +457,12 @@ export default class Block {
448
457
  * @returns {Object|null} Previous block's info or null
449
458
  */
450
459
  getPrevBlockInfo() {
460
+ // See getNextBlockInfo: from a shared layout-area block, "previous" is the
461
+ // last content section of the active page (e.g. a footer adapting to it).
462
+ const active = this.website?.activePage
463
+ if (active && active !== this.page) {
464
+ return active.getLastBodyBlockInfo()
465
+ }
451
466
  const index = this.getIndex()
452
467
  if (index <= 0 || !this.page) return null
453
468
  return this.page.getBlockInfo(index - 1)
package/src/page.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import Block from './block.js'
9
9
  import ObservableState from './observable-state.js'
10
+ import { normalizeSeo } from './seo.js'
10
11
 
11
12
  // Normalize a page's nav-visibility into a deduped hideIn array (the layout-area
12
13
  // names the page is suppressed from). Reads the canonical hideIn (array, or a single
@@ -63,16 +64,8 @@ export default class Page {
63
64
  params: pageData.layout?.params || {},
64
65
  }
65
66
 
66
- // SEO configuration
67
- this.seo = {
68
- noindex: pageData.seo?.noindex || false,
69
- image: pageData.seo?.image || null,
70
- ogTitle: pageData.seo?.ogTitle || null,
71
- ogDescription: pageData.seo?.ogDescription || null,
72
- canonical: pageData.seo?.canonical || null,
73
- changefreq: pageData.seo?.changefreq || null,
74
- priority: pageData.seo?.priority || null,
75
- }
67
+ // SEO configuration (canonical shape — shared with the site level via normalizeSeo)
68
+ this.seo = normalizeSeo(pageData.seo)
76
69
 
77
70
  // Parent page (set by Website.buildPageHierarchy())
78
71
  this.parent = null
@@ -159,16 +152,22 @@ export default class Page {
159
152
  */
160
153
  getHeadMeta() {
161
154
  const resolvedTitle = this.getTitle()
155
+ // Site-level seo provides the defaults; a page overrides any field it sets.
156
+ // (Website.seo / Website.keywords are the normalized site-root values.)
157
+ const site = this.website?.seo || {}
158
+ const siteKeywords = this.website?.keywords || null
162
159
  return {
163
160
  title: resolvedTitle,
164
161
  description: this.description,
165
- keywords: this.keywords,
162
+ keywords: this.keywords || siteKeywords,
166
163
  canonical: this.seo.canonical,
167
- robots: this.seo.noindex ? 'noindex, nofollow' : null,
164
+ robots: this.seo.noindex || site.noindex ? 'noindex, nofollow' : null,
168
165
  og: {
169
- title: this.seo.ogTitle || resolvedTitle,
170
- description: this.seo.ogDescription || this.description,
171
- image: this.seo.image,
166
+ // Page content wins; site-level seo only fills gaps. Image is the
167
+ // exception authors most want cascaded (the shared social card).
168
+ title: this.seo.ogTitle || resolvedTitle || site.ogTitle,
169
+ description: this.seo.ogDescription || this.description || site.ogDescription,
170
+ image: this.seo.image || site.image,
172
171
  url: this.route,
173
172
  },
174
173
  }
@@ -266,6 +265,18 @@ export default class Page {
266
265
  return this.bodyBlocks?.[0]?.getBlockInfo() || null
267
266
  }
268
267
 
268
+ /**
269
+ * Get the last body block's info.
270
+ * Symmetric to getFirstBodyBlockInfo — e.g. a footer adapting to the
271
+ * content section directly above it.
272
+ *
273
+ * @returns {Object|null} Last body block's info or null
274
+ */
275
+ getLastBodyBlockInfo() {
276
+ const blocks = this.bodyBlocks
277
+ return blocks?.[blocks.length - 1]?.getBlockInfo() || null
278
+ }
279
+
269
280
  /**
270
281
  * Get all blocks (header, body, footer) as flat array
271
282
  * Respects page layout preferences (hide list)
package/src/seo.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Canonical SEO / social-card shape.
3
+ *
4
+ * Shared by Website (site-level defaults) and Page (per-page), so the same
5
+ * concept has the same shape at every level. Site `seo` cascades as the
6
+ * default; a page overrides any field it sets (see Page.getHeadMeta).
7
+ *
8
+ * Field names are camelCase to match the framework's authored convention
9
+ * (`defaultLanguage`, etc.). `changefreq`/`priority` are sitemap hints — only
10
+ * meaningful per-page, but carried uniformly so the shape never drifts.
11
+ *
12
+ * @param {Object} [raw] - raw `seo:` block from site.yml / page.yml
13
+ * @returns {{ noindex: boolean, image: string|null, ogTitle: string|null,
14
+ * ogDescription: string|null, canonical: string|null, changefreq: string|null,
15
+ * priority: (string|number)|null }}
16
+ */
17
+ export function normalizeSeo(raw) {
18
+ const seo = raw || {}
19
+ return {
20
+ noindex: seo.noindex || false,
21
+ image: seo.image || null,
22
+ ogTitle: seo.ogTitle || null,
23
+ ogDescription: seo.ogDescription || null,
24
+ canonical: seo.canonical || null,
25
+ changefreq: seo.changefreq || null,
26
+ priority: seo.priority || null,
27
+ }
28
+ }
package/src/website.js CHANGED
@@ -9,6 +9,7 @@ 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 { normalizeSeo } from './seo.js'
12
13
 
13
14
  /**
14
15
  * Website — orchestration root for a single site instance.
@@ -73,6 +74,8 @@ export default class Website {
73
74
  this.pageRoutes = []
74
75
  this.themeData = {}
75
76
  this.config = {}
77
+ this.seo = {} // site-level SEO/social defaults (normalized; cascade to pages)
78
+ this.keywords = null // site-level default keywords (localized list or null)
76
79
  this.siteDefaultLocale = 'en'
77
80
  this.defaultLocale = 'en'
78
81
  this.activeLocale = 'en'
@@ -153,6 +156,12 @@ export default class Website {
153
156
  this.themeData = theme
154
157
  this.config = config
155
158
 
159
+ // Site-level SEO/social metadata — defaults that cascade to every page's
160
+ // effective head meta (Page.getHeadMeta). `config.seo` / `config.keywords`
161
+ // ride the raw site.yml spread; normalize seo to the canonical shape here.
162
+ this.seo = normalizeSeo(config.seo)
163
+ this.keywords = config.keywords || null
164
+
156
165
  this.siteDefaultLocale = config.defaultLanguage || 'en'
157
166
  this.defaultLocale = config.domainLocale || this.siteDefaultLocale
158
167
  this.activeLocale = config.activeLocale || this.defaultLocale