@uniweb/runtime 1.2.2 → 1.2.4
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/dist/ssr.js +32 -4
- package/dist/ssr.js.map +1 -1
- package/package.json +6 -4
- package/src/components/PageRenderer.jsx +2 -1
- package/src/head-meta.js +38 -0
- package/src/hooks/useHeadMeta.js +17 -1
- package/src/load-page-data.js +5 -0
- package/src/page-renderer.js +33 -8
- package/src/ssr-renderer.js +19 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -36,15 +36,16 @@
|
|
|
36
36
|
"node": ">=22.12"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@uniweb/core": "~1.4.
|
|
40
|
-
"@uniweb/theming": "~1.2.1"
|
|
39
|
+
"@uniweb/core": "~1.4.8",
|
|
40
|
+
"@uniweb/theming": "~1.2.1",
|
|
41
|
+
"@uniweb/projections": "~1.2.2"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@vitejs/plugin-react": "^4.5.2",
|
|
44
45
|
"esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
|
|
45
46
|
"vite": "^7.3.1",
|
|
46
47
|
"vitest": "^4.1.7",
|
|
47
|
-
"@uniweb/build": "1.
|
|
48
|
+
"@uniweb/build": "1.10.5"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
51
|
"react": "^19.0.0",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"uniweb": {
|
|
55
56
|
"bundledDeps": [
|
|
56
57
|
"@uniweb/core",
|
|
58
|
+
"@uniweb/projections",
|
|
57
59
|
"@uniweb/theming"
|
|
58
60
|
]
|
|
59
61
|
},
|
|
@@ -11,6 +11,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
11
11
|
import BlockRenderer from './BlockRenderer.jsx'
|
|
12
12
|
import Layout from './Layout.jsx'
|
|
13
13
|
import { useHeadMeta } from '../hooks/useHeadMeta.js'
|
|
14
|
+
import { pageHeadMeta } from '../head-meta.js'
|
|
14
15
|
import { useSectionViews } from '../hooks/useSectionViews.js'
|
|
15
16
|
import { buildSectionOverrides } from '@uniweb/theming'
|
|
16
17
|
import { carryQuery } from '@uniweb/core/resolve-route'
|
|
@@ -182,7 +183,7 @@ export default function PageRenderer() {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
// Head metadata — compute for all cases (redirect pages get a fallback)
|
|
185
|
-
const headMeta = page?.getHeadMeta
|
|
186
|
+
const headMeta = (typeof page?.getHeadMeta === 'function' && pageHeadMeta(page)) || {
|
|
186
187
|
title: isNotFound ? 'Page Not Found' : (page?.title || 'Website'),
|
|
187
188
|
description: page?.description || ''
|
|
188
189
|
}
|
package/src/head-meta.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page's head metadata, as both of its writers state it: the prerender (`injectPageContent`)
|
|
3
|
+
* and the browser (`useHeadMeta`).
|
|
4
|
+
*
|
|
5
|
+
* ⭐ `Page#getHeadMeta`'s, plus the address of the page's markdown (`markdown`), which the head
|
|
6
|
+
* links with `<link rel="alternate" type="text/markdown">` so an agent that lands on the HTML finds
|
|
7
|
+
* the document. The address is the one the site index gives the page, for the pages a markdown
|
|
8
|
+
* file is written for (`pageMarkdownUrls`, `@uniweb/projections`). It is added here rather than
|
|
9
|
+
* in core because core cannot depend on the package whose rules decide it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { pageMarkdownUrls } from '@uniweb/projections/markdown-urls'
|
|
13
|
+
|
|
14
|
+
// A site's markdown addresses, worked out once for its content. `rebuild({ content })` gives the
|
|
15
|
+
// Website a new `pages` array, so that array is the key and a rebuilt site is worked out again.
|
|
16
|
+
const urlsOfPages = new WeakMap()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Object} page - a Page
|
|
20
|
+
* @returns {Object} `page.getHeadMeta()`, and `markdown`: the URL of the page's markdown, or null —
|
|
21
|
+
* for a page the index leaves out and the not-found page, and for a record's page made on
|
|
22
|
+
* request (`uniweb dev`, a host), which is not among the content's pages. A static build's
|
|
23
|
+
* record pages are: its prerender writes them into the content each page boots with.
|
|
24
|
+
*/
|
|
25
|
+
export function pageHeadMeta(page) {
|
|
26
|
+
return { ...page.getHeadMeta(), markdown: markdownUrlOf(page) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markdownUrlOf(page) {
|
|
30
|
+
const website = page?.website
|
|
31
|
+
if (!website) return null
|
|
32
|
+
let urls = urlsOfPages.get(website.pages)
|
|
33
|
+
if (!urls) {
|
|
34
|
+
urls = pageMarkdownUrls({ pages: website.getContentPages(), config: website.config })
|
|
35
|
+
urlsOfPages.set(website.pages, urls)
|
|
36
|
+
}
|
|
37
|
+
return urls.get(page.route) ?? null
|
|
38
|
+
}
|
package/src/hooks/useHeadMeta.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Updates meta tags when page changes in SPA navigation.
|
|
6
6
|
*
|
|
7
7
|
* ⭐ The browser twin of `injectPageContent`'s head tags, from the same source
|
|
8
|
-
* (`
|
|
8
|
+
* (`pageHeadMeta`), so a crawler that runs the page's script reads what one that
|
|
9
9
|
* does not read. These tags are the framework's: each is set when the page has a value
|
|
10
10
|
* and removed when it has none — as it must be, or a tag of the page before survives a
|
|
11
11
|
* navigation. ⛔ Until 2026-10-25 this removed the canonical of every page that did not
|
|
@@ -116,6 +116,20 @@ function setAlternates(alternates) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The link to the page's markdown, replacing whatever the document carried
|
|
121
|
+
* @param {string|null} href
|
|
122
|
+
*/
|
|
123
|
+
function setMarkdownLink(href) {
|
|
124
|
+
for (const link of document.querySelectorAll('link[rel="alternate"][type="text/markdown"]')) link.remove()
|
|
125
|
+
if (!href) return
|
|
126
|
+
const link = document.createElement('link')
|
|
127
|
+
link.setAttribute('rel', 'alternate')
|
|
128
|
+
link.setAttribute('type', 'text/markdown')
|
|
129
|
+
link.setAttribute('href', href)
|
|
130
|
+
document.head.appendChild(link)
|
|
131
|
+
}
|
|
132
|
+
|
|
119
133
|
/**
|
|
120
134
|
* useHeadMeta hook
|
|
121
135
|
*
|
|
@@ -189,6 +203,7 @@ export function useHeadMeta(meta, options = {}) {
|
|
|
189
203
|
}
|
|
190
204
|
|
|
191
205
|
setAlternates(meta.alternates)
|
|
206
|
+
setMarkdownLink(meta.markdown || null)
|
|
192
207
|
|
|
193
208
|
// Cleanup function - reset to defaults on unmount
|
|
194
209
|
return () => {
|
|
@@ -209,6 +224,7 @@ export function useHeadMeta(meta, options = {}) {
|
|
|
209
224
|
meta?.og?.siteName,
|
|
210
225
|
meta?.twitter?.site,
|
|
211
226
|
JSON.stringify(meta?.alternates || []),
|
|
227
|
+
meta?.markdown,
|
|
212
228
|
siteName
|
|
213
229
|
])
|
|
214
230
|
}
|
package/src/load-page-data.js
CHANGED
|
@@ -88,6 +88,11 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
88
88
|
'(`initPrerenderForLocale`) rather than asking this step for another language.'
|
|
89
89
|
)
|
|
90
90
|
}
|
|
91
|
+
// ⭐ This graph is the one `globalThis.uniweb` names while the step reads it, which it does here,
|
|
92
|
+
// before its first `await`: each block's component meta and the layout the page draws come
|
|
93
|
+
// through the global, and a host holding several graphs in one isolate has it naming the newest
|
|
94
|
+
// (`Website#makeCurrent`).
|
|
95
|
+
website.makeCurrent()
|
|
91
96
|
const resolved = typeof route === 'string' ? resolvePage(website, route) : route
|
|
92
97
|
// Nothing matched: a genuine 404, and the caller's to turn into one. Nothing to fetch.
|
|
93
98
|
if (!resolved) return []
|
package/src/page-renderer.js
CHANGED
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
* cache headers are host layout, and a runtime that assembled them would be
|
|
22
22
|
* modelling a deployment it cannot see (the host drew this line itself,
|
|
23
23
|
* 2026-09-03: a serve location is read from the payload, never constructed here).
|
|
24
|
-
* - **Init and
|
|
25
|
-
* build initializes once per locale
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
24
|
+
* - **Init and the data step.** The two lanes differ REALLY here, not incidentally: a
|
|
25
|
+
* build initializes once per locale and runs the data step for each page over one
|
|
26
|
+
* cache for the whole run; an isolate initializes per locale and runs the step fresh
|
|
27
|
+
* for each request. Folding either in would fit one caller and lie to the other.
|
|
28
|
+
* `initPrerender` / `initPrerenderForLocale` / `loadPageData` / `hydrateDataStore`
|
|
29
|
+
* stay separate exports, and `prefetchAndHydrate` below is the isolate's two-step,
|
|
30
|
+
* not a general one. ⭐ What both lanes DO share is the last step: the render reads
|
|
31
|
+
* exactly the entries its page carries. That is `render`'s `fetchedData`, since
|
|
32
|
+
* 2026-10-25, and both lanes pass it.
|
|
31
33
|
* - **Anything build-only.** `injectBuildData` stays in the build lane; it is the
|
|
32
34
|
* other half of the head seam and has its own parity guard.
|
|
33
35
|
*/
|
|
@@ -65,6 +67,12 @@ export function createPageRenderer({ website, shell }) {
|
|
|
65
67
|
* Page, which the build lane already holds from its own loop.
|
|
66
68
|
* @param {Object} [options]
|
|
67
69
|
* @param {Object} [options.inject] - extra options forwarded to `injectPageContent`
|
|
70
|
+
* @param {Array<{config: Object, data: *, meta?: Object}>} [options.fetchedData] - the entries
|
|
71
|
+
* the page carries: the `content.fetchedData` its HTML embeds, which the browser boots with.
|
|
72
|
+
* ⭐ Given, the render reads exactly these. The Website forgets what it held, answers and the
|
|
73
|
+
* parametric pages made from them (`Website#forgetData`), and files these, all in this
|
|
74
|
+
* synchronous call, so a Website reused across requests renders each against its own and
|
|
75
|
+
* no other request runs in between. Absent, the render reads whatever the store holds.
|
|
68
76
|
* @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
|
|
69
77
|
* page: Object|null, error: {type: string, message: string}|null }}
|
|
70
78
|
* `notFound` means the URL names nothing, and `html` says whether there is a page to
|
|
@@ -75,7 +83,24 @@ export function createPageRenderer({ website, shell }) {
|
|
|
75
83
|
* - `page` set, `html: null`, `error` set — that not-found state broke while rendering.
|
|
76
84
|
* Send your own not-found page; `error` says what broke.
|
|
77
85
|
*/
|
|
78
|
-
function render(target, { inject = {} } = {}) {
|
|
86
|
+
function render(target, { inject = {}, fetchedData } = {}) {
|
|
87
|
+
if (fetchedData !== undefined && !Array.isArray(fetchedData)) {
|
|
88
|
+
throw new Error('createPageRenderer: `fetchedData` must be the array of entries the page carries')
|
|
89
|
+
}
|
|
90
|
+
// ⭐ FIRST, THIS GRAPH IS THE ONE `globalThis.uniweb` NAMES. A block's component, the props
|
|
91
|
+
// pipeline and kit in the foundation reach the site through the global, and every graph built
|
|
92
|
+
// since this one was points it elsewhere — a host holds several in one isolate. The render is
|
|
93
|
+
// synchronous, so the value set here is the one it reads throughout (`Website#makeCurrent`).
|
|
94
|
+
website.makeCurrent()
|
|
95
|
+
// ⭐ BEFORE THE ROUTE IS RESOLVED: a parametric page is made from the store when it is first
|
|
96
|
+
// resolved, so resolving first would make it from what an earlier request left there.
|
|
97
|
+
// ⛔ Until 2026-10-25 a host that reused one Website hydrated each request's answers on top of
|
|
98
|
+
// every earlier request's, and a section that reads the store itself (kit's `useFetched`)
|
|
99
|
+
// could render answers its page did not carry, so the browser's first render differed.
|
|
100
|
+
if (fetchedData !== undefined) {
|
|
101
|
+
website.forgetData()
|
|
102
|
+
hydrateDataStore(website, fetchedData)
|
|
103
|
+
}
|
|
79
104
|
const resolved = typeof target === 'string' ? resolvePage(website, target) : target
|
|
80
105
|
|
|
81
106
|
// ⛔ Not an error: nothing matched, which is a genuine 404 and the caller's to
|
package/src/ssr-renderer.js
CHANGED
|
@@ -37,6 +37,7 @@ import { layoutStickiness, sectionStickyProps, sectionScrollMargin, zoneProps }
|
|
|
37
37
|
import { DefaultLayout } from './default-layout.js'
|
|
38
38
|
import { renderAppearanceBootScript } from './appearance.js'
|
|
39
39
|
import { documentTitle } from './document-title.js'
|
|
40
|
+
import { pageHeadMeta } from './head-meta.js'
|
|
40
41
|
import { escapeHtml } from '@uniweb/core/page-shell'
|
|
41
42
|
|
|
42
43
|
// Re-export L2 helpers so the public `@uniweb/runtime/ssr` surface
|
|
@@ -445,10 +446,16 @@ export async function prefetchIcons(siteContent, uniweb, onProgress = () => {})
|
|
|
445
446
|
const url = iconUrl(family, name, cdnBase)
|
|
446
447
|
const response = await fetch(url)
|
|
447
448
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
448
|
-
|
|
449
|
-
uniweb.iconCache.set(`${family}:${name}`, svg)
|
|
449
|
+
return [`${family}:${name}`, await response.text()]
|
|
450
450
|
})
|
|
451
451
|
)
|
|
452
|
+
// ⭐ Cached in the order the build names the icons (`icons.used`, sorted), never the order their
|
|
453
|
+
// fetches settle in: the cache is written into every page (`_iconCache`, `__ICON_CACHE__`), so
|
|
454
|
+
// in arrival order two builds of one site wrote different pages. ⛔ Until 2026-10-25 each icon
|
|
455
|
+
// was cached as it arrived (measured: three builds of one site, three orders).
|
|
456
|
+
for (const result of results) {
|
|
457
|
+
if (result.status === 'fulfilled') uniweb.iconCache.set(...result.value)
|
|
458
|
+
}
|
|
452
459
|
|
|
453
460
|
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
|
454
461
|
const failedRefs = icons.filter((_, i) => results[i].status === 'rejected')
|
|
@@ -578,7 +585,8 @@ export { escapeHtml }
|
|
|
578
585
|
* Common operations shared by both build and cloud:
|
|
579
586
|
* - Replace #root div with rendered HTML
|
|
580
587
|
* - Update page title
|
|
581
|
-
* - Write the page's head tags — description, canonical, hreflang, Open Graph
|
|
588
|
+
* - Write the page's head tags — description, canonical, hreflang, Open Graph, the link to its
|
|
589
|
+
* markdown (`writeHeadMeta`)
|
|
582
590
|
* - Inject section override CSS
|
|
583
591
|
* - Inject the pre-paint appearance script
|
|
584
592
|
*
|
|
@@ -670,9 +678,10 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
|
|
|
670
678
|
)
|
|
671
679
|
}
|
|
672
680
|
|
|
673
|
-
// ⭐ THE PAGE'S HEAD TAGS — description, canonical, hreflang, Open Graph, Twitter
|
|
674
|
-
// page's own head metadata (`
|
|
675
|
-
// `useHeadMeta`), so crawlers
|
|
681
|
+
// ⭐ THE PAGE'S HEAD TAGS — description, canonical, hreflang, Open Graph, Twitter, the link to its
|
|
682
|
+
// markdown — from the page's own head metadata (`pageHeadMeta`, which the browser writes the same
|
|
683
|
+
// tags from in `useHeadMeta`), so crawlers, social unfurlers and agents, which run no script,
|
|
684
|
+
// read the page's own.
|
|
676
685
|
// ⛔ Until 2026-10-25 these were APPENDED, after the static build had written the HOME page's
|
|
677
686
|
// into the one shell every page is rendered into: each page named the home page its canonical
|
|
678
687
|
// and carried two og:title and two og:url, the home page's first.
|
|
@@ -692,8 +701,8 @@ export function injectPageContent(html, renderedContent, page, options = {}) {
|
|
|
692
701
|
* @returns {string}
|
|
693
702
|
*/
|
|
694
703
|
function writeHeadMeta(html, page) {
|
|
695
|
-
// A Page states its own (`
|
|
696
|
-
const meta = typeof page?.getHeadMeta === 'function' ? page
|
|
704
|
+
// A Page states its own (`pageHeadMeta`); plain page data, which callers may hand in, its fields.
|
|
705
|
+
const meta = typeof page?.getHeadMeta === 'function' ? pageHeadMeta(page) : { description: page?.description || null }
|
|
697
706
|
const end = html.indexOf('</head>')
|
|
698
707
|
if (!meta || end === -1) return html
|
|
699
708
|
const og = meta.og || {}
|
|
@@ -727,6 +736,8 @@ function writeHeadMeta(html, page) {
|
|
|
727
736
|
for (const { hreflang, url } of meta.alternates || []) {
|
|
728
737
|
written.push(`<link rel="alternate" hreflang="${escapeHtml(hreflang)}" href="${escapeHtml(url)}">`)
|
|
729
738
|
}
|
|
739
|
+
head = head.replace(/<link\b(?=[^>]*\brel=["']alternate["'])(?=[^>]*\btype=["']text\/markdown["'])[^>]*>\s*/gi, '')
|
|
740
|
+
if (meta.markdown) written.push(`<link rel="alternate" type="text/markdown" href="${escapeHtml(meta.markdown)}">`)
|
|
730
741
|
return `${head}${written.join('\n')}\n${html.slice(end)}`
|
|
731
742
|
}
|
|
732
743
|
|