@uniweb/build 0.54.1 → 0.55.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 +6 -6
- package/src/prerender.js +66 -93
- package/src/site/data-fetcher.js +17 -6
- package/src/site/file-transport.js +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"@uniweb/content-reader": "^1.2.5",
|
|
61
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
62
|
"@uniweb/projections": "^0.7.3",
|
|
63
|
+
"@uniweb/theming": "^0.1.15",
|
|
63
64
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
64
|
-
"@uniweb/schemas": "^0.3.3"
|
|
65
|
-
"@uniweb/theming": "^0.1.15"
|
|
65
|
+
"@uniweb/schemas": "^0.3.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.
|
|
68
|
+
"@uniweb/runtime": "^0.27.0"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/core": "^0.29.
|
|
78
|
-
"@uniweb/runtime": "^0.
|
|
77
|
+
"@uniweb/core": "^0.29.6",
|
|
78
|
+
"@uniweb/runtime": "^0.27.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/prerender.js
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
evaluateQuery,
|
|
26
26
|
} from '@uniweb/core'
|
|
27
27
|
import { recordTitle, routePatternToRegex } from '@uniweb/core/route-match'
|
|
28
|
+
import DataStore from '@uniweb/core/datastore'
|
|
28
29
|
import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
|
|
30
|
+
import { createFileTransport } from './site/file-transport.js'
|
|
29
31
|
import { shouldSplitContent } from './site/split-content.js'
|
|
30
32
|
import { FONT_LINKS_MARKER } from './site/head-markers.js'
|
|
31
33
|
import { getAdapter } from './hosts/index.js'
|
|
@@ -89,9 +91,10 @@ export function resolveExtensionPath(url, distDir, projectRoot, base) {
|
|
|
89
91
|
* @param {string} [localeInfo.locale] - Active locale code
|
|
90
92
|
* @param {string} [localeInfo.defaultLocale] - Default locale code
|
|
91
93
|
* @param {string} [localeInfo.distDir] - Path to dist directory (where locale-specific data lives)
|
|
92
|
-
* @returns {Object} { fetched, fetchedData
|
|
93
|
-
*
|
|
94
|
-
*
|
|
94
|
+
* @returns {Object} { fetched, fetchedData } - what each level fetched, by binding key (for
|
|
95
|
+
* parametric-page expansion), and the entries for DataStore pre-population. ⛔ It no longer
|
|
96
|
+
* bakes the views an expanded page binds to its route: the render loop asks for those by the
|
|
97
|
+
* rule the render uses, per page (`loadPageData`, 2026-09-20).
|
|
95
98
|
*/
|
|
96
99
|
export async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
|
|
97
100
|
// The locale this pass renders, which a `sort` collates texts in.
|
|
@@ -171,7 +174,15 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
171
174
|
// `undefined` — which passes the `!== false` test and then fetches nothing.
|
|
172
175
|
const firstSite = firstOfKey()
|
|
173
176
|
for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
|
|
174
|
-
|
|
177
|
+
// ⭐ EVERY binding is FETCHED; only the first per key is KEPT as the key's set.
|
|
178
|
+
// Ruled 2026-09-20 [Diego]: where several fetches of one schema meet several declared
|
|
179
|
+
// keys of it and no `as` pairs them, they pair POSITIONALLY — so a second binding under
|
|
180
|
+
// one key fills another key and its data is needed. ⛔ Skipping it baked nothing for that
|
|
181
|
+
// key and the section rendered empty. What stays first-per-key is the SET a parametric
|
|
182
|
+
// page expands over, which is the route query's — and it is counted before
|
|
183
|
+
// `prerender: false` is honoured, so a deferred first binding is not replaced by a baked
|
|
184
|
+
// second.
|
|
185
|
+
const isFirst = firstSite(oneFetch.as)
|
|
175
186
|
// ⭐ `prerender` is read off the RESOLVED config: an external query's binding is the
|
|
176
187
|
// browser's unless it says otherwise, which only the resolver knows.
|
|
177
188
|
const cfg = resolveForBuild(oneFetch)
|
|
@@ -180,7 +191,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
180
191
|
const got = await read(cfg, oneFetch)
|
|
181
192
|
if (got) {
|
|
182
193
|
fetchedData.push(entry(cfg, got.view, '__site__'))
|
|
183
|
-
fetched.site.set(oneFetch.as, got.selection)
|
|
194
|
+
if (isFirst) fetched.site.set(oneFetch.as, got.selection)
|
|
184
195
|
}
|
|
185
196
|
}
|
|
186
197
|
|
|
@@ -201,14 +212,15 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
201
212
|
// Page-level fetch — every declaration on the page.
|
|
202
213
|
const firstPage = firstOfKey()
|
|
203
214
|
for (const oneFetch of toFetchList(page.fetch)) {
|
|
204
|
-
|
|
215
|
+
// Every binding fetched, the first per key kept as the key's set — see the site level.
|
|
216
|
+
const isFirst = firstPage(oneFetch.as)
|
|
205
217
|
const cfg = resolveForBuild(oneFetch)
|
|
206
218
|
if (cfg.prerender === false) continue
|
|
207
219
|
onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
|
|
208
220
|
const got = await read(cfg, oneFetch)
|
|
209
221
|
if (got) {
|
|
210
222
|
fetchedData.push(entry(cfg, got.view, page.route))
|
|
211
|
-
keep(fetched.pages, page.route, oneFetch.as, got.selection)
|
|
223
|
+
if (isFirst) keep(fetched.pages, page.route, oneFetch.as, got.selection)
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
226
|
|
|
@@ -218,7 +230,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
218
230
|
// key, a view its route binds under any other. A list baked into the template
|
|
219
231
|
// was cloned into every expanded page and outranked both (measured 2026-09-13:
|
|
220
232
|
// `/blog/b` rendered all three records). Its views are read per expanded page
|
|
221
|
-
// instead (`
|
|
233
|
+
// instead — by the render loop's data step, per page (`loadPageData`).
|
|
222
234
|
await processSectionFetches(page.sections, {
|
|
223
235
|
resolve: resolveForBuild,
|
|
224
236
|
read,
|
|
@@ -228,21 +240,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
228
240
|
})
|
|
229
241
|
}
|
|
230
242
|
|
|
231
|
-
|
|
232
|
-
* Read one config the runtime resolved for an expanded parametric page — a view
|
|
233
|
-
* its route binds — into a plain entry; `readRouteBoundViews` files it under
|
|
234
|
-
* each page that asks for it. Local files only: a remote `url:` is the
|
|
235
|
-
* browser's, as it is by default. Null when there is nothing to embed.
|
|
236
|
-
*/
|
|
237
|
-
const bake = async (cfg) => {
|
|
238
|
-
if (cfg.prerender === false || (typeof cfg.path !== 'string' && typeof cfg.url !== 'string')) return null
|
|
239
|
-
const options = isNonDefaultLocale && typeof cfg.path === 'string' && cfg.path.startsWith(`/${localeInfo.locale}/`) ? localizedFetchOptions : fetchOptions
|
|
240
|
-
const result = await executeFetch(cfg, options)
|
|
241
|
-
if (!result.data || result.error) return null
|
|
242
|
-
return { config: cfg, data: result.data, meta: { whole: cfg.whole } }
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return { fetched, fetchedData, bake }
|
|
243
|
+
return { fetched, fetchedData }
|
|
246
244
|
}
|
|
247
245
|
|
|
248
246
|
/**
|
|
@@ -622,7 +620,7 @@ function stripFetchScope(entry) {
|
|
|
622
620
|
*/
|
|
623
621
|
export function scopeFetchedData(fetchedData, scopeRoutes, currentRoute = null) {
|
|
624
622
|
if (!Array.isArray(fetchedData)) return fetchedData
|
|
625
|
-
// A route-bound entry
|
|
623
|
+
// A route-bound entry — what a page's own data step asked for — belongs to that page, in
|
|
626
624
|
// either mode: carried everywhere, a site of N such pages would embed N views —
|
|
627
625
|
// or N whole records — in every page.
|
|
628
626
|
const own = (e) => !e._routeBound || e._scope === currentRoute
|
|
@@ -663,54 +661,6 @@ function dedupeByAddress(entries) {
|
|
|
663
661
|
return out
|
|
664
662
|
}
|
|
665
663
|
|
|
666
|
-
/**
|
|
667
|
-
* The views an expanded parametric page binds to its route — `scope: :dir` bound to
|
|
668
|
-
* its branch, a `deferred:` query's per-record file — that no list page asked for.
|
|
669
|
-
* Each is resolved by the RUNTIME's own rule for the page (`resolvePageFetchConfigs`,
|
|
670
|
-
* the one a host's prefetch calls, matched against the parametric page it came
|
|
671
|
-
* from) and read by `read`, so the page renders complete and the SPA hydrates the
|
|
672
|
-
* very keys it asks for.
|
|
673
|
-
*
|
|
674
|
-
* ⭐ ONE ENTRY PER PAGE, ONE READ PER VIEW. Each entry is tagged `_routeBound` with
|
|
675
|
-
* its page's route: `scopeFetchedData` embeds it in that page's HTML and nowhere
|
|
676
|
-
* else, and the split-mode manifest leaves it out — so N expanded pages do not
|
|
677
|
-
* carry N views (or N whole records) each. Pages that bind the same view (two
|
|
678
|
-
* entries in one branch) each get an entry, over a single read. ⛔ Filed under
|
|
679
|
-
* the first page that asked for it, a shared view reached no other page's HTML
|
|
680
|
-
* (measured: the second entry in a branch shipped without its branch's view).
|
|
681
|
-
*
|
|
682
|
-
* A key already `present` is not read: every page carries those already — the
|
|
683
|
-
* site's, and, in split mode, its parent's and its template's (the render loop's
|
|
684
|
-
* `scopeRoutes`).
|
|
685
|
-
*
|
|
686
|
-
* @param {Object} options
|
|
687
|
-
* @param {Object} options.templates - the content as it was before expansion (the parametric pages)
|
|
688
|
-
* @param {Array<Object>} options.pages - the pages after expansion
|
|
689
|
-
* @param {Array<Object>} options.present - the entries already baked
|
|
690
|
-
* @param {Function} options.resolvePageFetchConfigs - `@uniweb/runtime/ssr`'s
|
|
691
|
-
* @param {(cfg: Object) => Promise<{config: Object, data: any, meta?: Object}|null>} options.read - reads one config
|
|
692
|
-
* @param {string|null} [options.locale]
|
|
693
|
-
* @returns {Promise<Array<Object>>} the new entries, each for its own page
|
|
694
|
-
*/
|
|
695
|
-
export async function readRouteBoundViews({ templates, pages, present, resolvePageFetchConfigs, read, locale = null }) {
|
|
696
|
-
const carried = new Set((present || []).map((e) => deriveCacheKey(e.config)))
|
|
697
|
-
const reads = new Map()
|
|
698
|
-
const out = []
|
|
699
|
-
for (const page of pages || []) {
|
|
700
|
-
if (!page?.dynamicContext) continue
|
|
701
|
-
const filed = new Set()
|
|
702
|
-
for (const cfg of resolvePageFetchConfigs(templates, page.route, { locale })) {
|
|
703
|
-
const key = deriveCacheKey(cfg)
|
|
704
|
-
if (carried.has(key) || filed.has(key)) continue
|
|
705
|
-
filed.add(key)
|
|
706
|
-
if (!reads.has(key)) reads.set(key, read(cfg))
|
|
707
|
-
const view = await reads.get(key)
|
|
708
|
-
if (view) out.push({ ...view, _scope: page.route, _routeBound: true })
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
return out
|
|
712
|
-
}
|
|
713
|
-
|
|
714
664
|
/**
|
|
715
665
|
* Inject build-specific data into HTML (theme CSS, __SITE_CONTENT__, icon cache).
|
|
716
666
|
* Called after the shared injectPageContent for build-specific additions.
|
|
@@ -862,7 +812,7 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
862
812
|
prefetchIcons,
|
|
863
813
|
createPageRenderer,
|
|
864
814
|
generate404Html,
|
|
865
|
-
|
|
815
|
+
loadPageData,
|
|
866
816
|
} = await import('@uniweb/runtime/ssr')
|
|
867
817
|
|
|
868
818
|
// Load default site content
|
|
@@ -941,30 +891,15 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
941
891
|
// Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
|
|
942
892
|
if (siteContent.pages?.some(p => p.isDynamic)) {
|
|
943
893
|
onProgress('Expanding dynamic routes...')
|
|
944
|
-
const templates = { ...siteContent, pages: siteContent.pages }
|
|
945
894
|
siteContent.pages = expandDynamicPages(siteContent.pages, fetched, onProgress, undefined, {
|
|
946
895
|
siteFetch: siteContent.config?.fetch ?? null,
|
|
947
896
|
})
|
|
948
897
|
|
|
949
|
-
// ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
// this build's executor. So the page renders complete, and the SPA hydrates
|
|
955
|
-
// the very keys it asks for.
|
|
956
|
-
const baked = await readRouteBoundViews({
|
|
957
|
-
templates,
|
|
958
|
-
pages: siteContent.pages,
|
|
959
|
-
present: siteContent.fetchedData,
|
|
960
|
-
resolvePageFetchConfigs,
|
|
961
|
-
read: bake,
|
|
962
|
-
locale,
|
|
963
|
-
})
|
|
964
|
-
if (baked.length > 0) {
|
|
965
|
-
siteContent.fetchedData = [...siteContent.fetchedData, ...baked]
|
|
966
|
-
onProgress(` Read ${baked.length} route-bound view(s) for expanded pages`)
|
|
967
|
-
}
|
|
898
|
+
// ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS — its branch's view, a `deferred:`
|
|
899
|
+
// query's per-record file — and that is now asked for in the render loop below, by the
|
|
900
|
+
// rule the render uses, per page (`loadPageData`). ⛔ It was worked out here from the
|
|
901
|
+
// payload alone until 2026-09-20, which cannot build a record's own address from the
|
|
902
|
+
// record: an `[id]` route over a `deferred:` query asked for `{slug}` unfilled.
|
|
968
903
|
}
|
|
969
904
|
|
|
970
905
|
// Determine whether to split content (after dynamic expansion, after data fetches)
|
|
@@ -1022,6 +957,16 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
1022
957
|
// fixed, so it is created once and asked per page.
|
|
1023
958
|
const renderer = createPageRenderer({ website: uniweb.activeWebsite, shell: htmlShell })
|
|
1024
959
|
|
|
960
|
+
// ⭐ The data step's two halves for this lane: a transport over the files this build has
|
|
961
|
+
// already produced, and one cache for the whole run so an address every page asks for is read
|
|
962
|
+
// once rather than once per page.
|
|
963
|
+
const fileTransport = createFileTransport({
|
|
964
|
+
siteRoot: siteDir,
|
|
965
|
+
distDir,
|
|
966
|
+
base: siteContent.config?.base || '',
|
|
967
|
+
})
|
|
968
|
+
const buildDataCache = new DataStore()
|
|
969
|
+
|
|
1025
970
|
// Build-specific: pre-populate DataStore so EntityStore can resolve data during prerender.
|
|
1026
971
|
// hydrateDataStore handles cache-key derivation + value-shape wrapping
|
|
1027
972
|
// — same helper used by the browser SPA boot and by the Cloudflare
|
|
@@ -1090,6 +1035,34 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
1090
1035
|
}
|
|
1091
1036
|
}
|
|
1092
1037
|
|
|
1038
|
+
// ⭐ WHAT THIS PAGE'S RENDER WILL READ — asked by the rule the render itself uses
|
|
1039
|
+
// (`loadPageData`, `@uniweb/runtime/ssr`) and answered from this build's own files. It walks
|
|
1040
|
+
// the page's blocks: its body, the layout areas it draws, child blocks and insets.
|
|
1041
|
+
//
|
|
1042
|
+
// ⛔ Until 2026-09-20 this lane worked the answer out its own way, and got two things wrong
|
|
1043
|
+
// that nothing else could see: a LAYOUT section's data was never fetched at all — the
|
|
1044
|
+
// executor walks the site, each page and each page's sections, and layout areas sit beside
|
|
1045
|
+
// the pages — and an expanded page's views came from the payload walk, which cannot build a
|
|
1046
|
+
// record's own address from the record. Both render empty in the HTML and fill in once the
|
|
1047
|
+
// SPA boots, so only a crawler ever saw them.
|
|
1048
|
+
//
|
|
1049
|
+
// One cache for the whole run, so an address every page asks for is read once; the author's
|
|
1050
|
+
// `prerender: false` is honoured, which is what `'author'` means and what a baking lane owes.
|
|
1051
|
+
const asked = await loadPageData({
|
|
1052
|
+
website,
|
|
1053
|
+
route: page,
|
|
1054
|
+
fetch: fileTransport,
|
|
1055
|
+
prerender: 'author',
|
|
1056
|
+
cache: buildDataCache,
|
|
1057
|
+
})
|
|
1058
|
+
hydrateDataStore(website, asked)
|
|
1059
|
+
// Filed under this page, the way a route-bound view always has been: `scopeFetchedData`
|
|
1060
|
+
// embeds it in this page's HTML and nowhere else, and one address embedded twice collapses.
|
|
1061
|
+
for (const entry of asked) {
|
|
1062
|
+
if (entry.outcome !== 'fetched') continue
|
|
1063
|
+
siteContent.fetchedData.push({ ...entry, _scope: page.route, _routeBound: true })
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1093
1066
|
onProgress(`Rendering ${outputRoute}...`)
|
|
1094
1067
|
|
|
1095
1068
|
// ⭐ `renderer.render` IS the sequence this loop used to spell out —
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -412,10 +412,19 @@ function refuseSourceKeys(fetch, context) {
|
|
|
412
412
|
|
|
413
413
|
/**
|
|
414
414
|
* ⚠️ TWO BINDINGS UNDER ONE KEY AT ONE LEVEL — ruled 2026-09-13 [Diego]: *"Duplicates
|
|
415
|
-
* should not exist."*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
415
|
+
* should not exist."* Warned, not refused, once per key per file.
|
|
416
|
+
*
|
|
417
|
+
* ⛔ **What happens to the second one depends on something this parser cannot see** — whether
|
|
418
|
+
* a component declares that key — so the message says both, and this comment no longer claims
|
|
419
|
+
* one. Ruled 2026-09-20 [Diego]: *"if there are x fetches of schema Y, and x+ keys for schema
|
|
420
|
+
* Y, and there is no `as` key that tells us how to pair, then we can auto-pair positionally."*
|
|
421
|
+
* - **a component declares the key** → the first fills it and the rest fill nothing;
|
|
422
|
+
* - **none declares it** → they pair POSITIONALLY with that schema's still-empty declared
|
|
423
|
+
* keys, in order, so the second is used (`fillDeclaredKeys`, measured 2026-09-20).
|
|
424
|
+
*
|
|
425
|
+
* ⚠️ This comment and the message said *"the rest are ignored"* until 2026-09-20, which is
|
|
426
|
+
* false in the second case — and the build's own executor believed it, so a second binding's
|
|
427
|
+
* data was never fetched and the key it fills rendered empty.
|
|
419
428
|
*
|
|
420
429
|
* @param {Array<Object>} list - one level's bindings, parsed or as authored
|
|
421
430
|
* @param {string} context - where the declaration sits, for the message
|
|
@@ -435,8 +444,10 @@ export function warnDuplicateBindings(list, context) {
|
|
|
435
444
|
if (warnedDuplicateBindings.has(memo)) continue
|
|
436
445
|
warnedDuplicateBindings.add(memo)
|
|
437
446
|
console.warn(
|
|
438
|
-
`[uniweb] ${context}: more than one binding
|
|
439
|
-
`and the rest
|
|
447
|
+
`[uniweb] ${context}: more than one binding is named ${key}. A component that declares ` +
|
|
448
|
+
`content.data.${key} receives the first, and the rest fill nothing; if none declares it, ` +
|
|
449
|
+
`they pair in order with the declared keys of their schema. Give each binding its own ` +
|
|
450
|
+
`\`as:\` to say which is which.`
|
|
440
451
|
)
|
|
441
452
|
}
|
|
442
453
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `fetch`-shaped transport over the files this build has already produced.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **Why a transport rather than a reader.** The render's data step
|
|
5
|
+
* (`@uniweb/runtime/ssr::loadPageData`) asks for what a page's render will read, by the rule the
|
|
6
|
+
* render itself uses, and dispatches every request through one function the caller supplies. A host
|
|
7
|
+
* gives it the network; this lane gives it `dist/` and `public/`. So the build asks the same
|
|
8
|
+
* question as every other lane and only the answering differs — which is the whole point of
|
|
9
|
+
* `kb/framework/plans/what-a-page-needs.md`.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ **A remote `url:` is still the browser's lane by default.** Nothing here decides that: the
|
|
12
|
+
* author's `prerender:` does, and the step is run with `prerender: 'author'` so a deferred request
|
|
13
|
+
* is never dispatched. One that IS asked for at build time is fetched the way a browser would.
|
|
14
|
+
*
|
|
15
|
+
* ⚖️ **`dist/` first, then `public/`** — a non-default locale's data is written under
|
|
16
|
+
* `dist/{locale}/data/…` and the resolver localizes the path, so the localized address is found
|
|
17
|
+
* where it was written; everything else sits in `public/`, which is where the query processor
|
|
18
|
+
* compiles a query's file.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync } from 'node:fs'
|
|
23
|
+
import { readFile } from 'node:fs/promises'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
/** The site-relative path an address names, with the site's base removed. */
|
|
27
|
+
function pathOf(target, base) {
|
|
28
|
+
const clean = String(target).split('#')[0]
|
|
29
|
+
if (!base || base === '/') return clean
|
|
30
|
+
const prefix = base.endsWith('/') ? base.slice(0, -1) : base
|
|
31
|
+
return clean.startsWith(prefix) ? clean.slice(prefix.length) || '/' : clean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const response = (body, contentType) => ({
|
|
35
|
+
ok: true,
|
|
36
|
+
status: 200,
|
|
37
|
+
statusText: 'OK',
|
|
38
|
+
headers: { get: (name) => (String(name).toLowerCase() === 'content-type' ? contentType : null) },
|
|
39
|
+
json: async () => JSON.parse(body),
|
|
40
|
+
text: async () => body,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const missing = (path) => ({
|
|
44
|
+
ok: false,
|
|
45
|
+
status: 404,
|
|
46
|
+
statusText: `no file for ${path}`,
|
|
47
|
+
headers: { get: () => 'text/plain' },
|
|
48
|
+
json: async () => null,
|
|
49
|
+
text: async () => '',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Object} where
|
|
54
|
+
* @param {string} where.siteRoot - the site directory
|
|
55
|
+
* @param {string|null} [where.distDir] - its build output, searched first
|
|
56
|
+
* @param {string} [where.publicDir] - the static directory under `siteRoot`
|
|
57
|
+
* @param {string} [where.base] - the site's base path, which addresses carry and files do not
|
|
58
|
+
* @returns {(input: *, init?: Object) => Promise<Object>} a `fetch`
|
|
59
|
+
*/
|
|
60
|
+
export function createFileTransport({ siteRoot, distDir = null, publicDir = 'public', base = '' }) {
|
|
61
|
+
const roots = [distDir, join(siteRoot, publicDir)].filter(Boolean)
|
|
62
|
+
|
|
63
|
+
return async function fileTransport(input, init) {
|
|
64
|
+
const target = String(input?.url ?? input)
|
|
65
|
+
// Absolute: a remote address, fetched as the browser would.
|
|
66
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return globalThis.fetch(target, init)
|
|
67
|
+
|
|
68
|
+
const path = pathOf(target, base)
|
|
69
|
+
for (const root of roots) {
|
|
70
|
+
const file = join(root, path)
|
|
71
|
+
if (!existsSync(file)) continue
|
|
72
|
+
const body = await readFile(file, 'utf8')
|
|
73
|
+
return response(body, path.endsWith('.json') ? 'application/json' : 'text/plain')
|
|
74
|
+
}
|
|
75
|
+
// ⚖️ Not an error here. A page can ask for a per-record file a `deferred:` query never wrote,
|
|
76
|
+
// and the step reports the outcome so the caller can see it rather than crash a build.
|
|
77
|
+
return missing(path)
|
|
78
|
+
}
|
|
79
|
+
}
|