@uniweb/build 0.37.1 → 0.38.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 +7 -9
- package/src/prerender.js +65 -22
- package/src/runtime-schema.js +17 -2
- package/src/schema.js +73 -0
- package/src/site/build-site-data.js +5 -0
- package/src/site/content-collector.js +59 -7
- package/src/site/data-fetcher.js +168 -28
- package/src/site/plugin.js +17 -5
- package/src/site/queries-config.js +2 -3
- package/src/site/query-processor.js +24 -7
- package/src/site/records-config.js +1 -1
- package/src/uwx/foundation-schema.js +10 -3
- package/src/uwx/records.js +22 -8
- package/src/uwx/registry-package.js +10 -1
- package/src/uwx/site-project.js +6 -1
- package/src/uwx/site.js +8 -4
- package/src/dev-backend.js +0 -247
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -23,9 +23,7 @@
|
|
|
23
23
|
"./uwx": "./src/uwx/index.js",
|
|
24
24
|
"./import-map-plugin": "./src/import-map-plugin.js"
|
|
25
25
|
},
|
|
26
|
-
"bin": {
|
|
27
|
-
"uniweb-dev-backend": "./src/dev-backend.js"
|
|
28
|
-
},
|
|
26
|
+
"bin": {},
|
|
29
27
|
"files": [
|
|
30
28
|
"src"
|
|
31
29
|
],
|
|
@@ -59,15 +57,15 @@
|
|
|
59
57
|
"js-yaml": "^4.1.0",
|
|
60
58
|
"sharp": "^0.35.3",
|
|
61
59
|
"yaml": "^2.5.0",
|
|
62
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
63
60
|
"@uniweb/content-reader": "^1.2.4",
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/projections": "^0.5.7",
|
|
61
|
+
"@uniweb/projections": "^0.5.8",
|
|
66
62
|
"@uniweb/schemas": "^0.2.13",
|
|
63
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
64
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
67
65
|
"@uniweb/theming": "^0.1.15"
|
|
68
66
|
},
|
|
69
67
|
"optionalDependencies": {
|
|
70
|
-
"@uniweb/runtime": "^0.
|
|
68
|
+
"@uniweb/runtime": "^0.15.0"
|
|
71
69
|
},
|
|
72
70
|
"peerDependencies": {
|
|
73
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -76,7 +74,7 @@
|
|
|
76
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
77
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
78
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
79
|
-
"@uniweb/core": "^0.
|
|
77
|
+
"@uniweb/core": "^0.20.0"
|
|
80
78
|
},
|
|
81
79
|
"peerDependenciesMeta": {
|
|
82
80
|
"vite": {
|
package/src/prerender.js
CHANGED
|
@@ -11,8 +11,8 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
|
11
11
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
12
12
|
import { join, dirname, resolve } from 'node:path'
|
|
13
13
|
import { pathToFileURL } from 'node:url'
|
|
14
|
-
import { resolveDefaultLocale,
|
|
15
|
-
import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
|
|
14
|
+
import { resolveDefaultLocale, resolveFetchConfigs, joinPathCapture, splitPathCapture } from '@uniweb/core'
|
|
15
|
+
import { executeFetch, mergeDataIntoContent, toFetchList, stripBuildOnlyFetchKeys } from './site/data-fetcher.js'
|
|
16
16
|
import { shouldSplitContent } from './site/split-content.js'
|
|
17
17
|
import { FONT_LINKS_MARKER } from './site/head-markers.js'
|
|
18
18
|
import { getAdapter } from './hosts/index.js'
|
|
@@ -78,37 +78,50 @@ export function resolveExtensionPath(url, distDir, projectRoot, base) {
|
|
|
78
78
|
* @param {string} [localeInfo.distDir] - Path to dist directory (where locale-specific data lives)
|
|
79
79
|
* @returns {Object} { pageFetchedData, fetchedData } - Fetched data for dynamic route expansion and DataStore pre-population
|
|
80
80
|
*/
|
|
81
|
-
async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
|
|
81
|
+
export async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
|
|
82
82
|
const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
|
|
83
83
|
const fetchedData = [] // Collected for DataStore pre-population
|
|
84
84
|
|
|
85
85
|
// For non-default locales, translated collection data lives in dist/{locale}/data/
|
|
86
|
-
// instead of public/data/.
|
|
86
|
+
// instead of public/data/.
|
|
87
87
|
const isNonDefaultLocale = localeInfo &&
|
|
88
88
|
localeInfo.locale !== localeInfo.defaultLocale &&
|
|
89
89
|
localeInfo.distDir
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
// ⭐ RESOLVED THE WAY THE RUNTIME RESOLVES IT — the one rule in
|
|
92
|
+
// `@uniweb/core/fetch-config`, which is what makes the entries below hydrate:
|
|
93
|
+
// the SPA looks each one up under `deriveCacheKey(config)` of ITS resolved
|
|
94
|
+
// config, so the config baked here must be the same object — the same
|
|
95
|
+
// localized path (this used to prefix `/{locale}` by hand), the same `depth`
|
|
96
|
+
// (a `deferred:` query's compiled file is a list of briefs), the same detail
|
|
97
|
+
// pattern. A hand-built copy of that rule is how a prerendered site came to
|
|
98
|
+
// refetch on boot without anyone noticing.
|
|
99
|
+
const resolveOptions = {
|
|
100
|
+
locale: localeInfo?.locale ?? null,
|
|
101
|
+
defaultLocale: localeInfo?.defaultLocale ?? null,
|
|
102
|
+
queries: siteContent.config?.queries ?? null,
|
|
103
|
+
records: null, // the build lane: no live records; the compiled file answers
|
|
94
104
|
}
|
|
105
|
+
const resolveForBuild = (oneFetch) =>
|
|
106
|
+
resolveFetchConfigs([oneFetch], resolveOptions).get(oneFetch.as) ?? oneFetch
|
|
95
107
|
|
|
96
108
|
// Fetch options pointing to dist/ for localized data
|
|
97
109
|
const localizedFetchOptions = isNonDefaultLocale
|
|
98
110
|
? { siteRoot: localeInfo.distDir, publicDir: '.' }
|
|
99
111
|
: fetchOptions
|
|
112
|
+
const optionsFor = (cfg, oneFetch) => (cfg.path !== oneFetch.path ? localizedFetchOptions : fetchOptions)
|
|
113
|
+
const entry = (cfg, data, scope) => ({ config: cfg, data, meta: { depth: cfg.depth }, _scope: scope })
|
|
100
114
|
|
|
101
115
|
// 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
|
|
102
116
|
// or `data:` LIST parses to an array, and `siteFetch.prerender` on one is
|
|
103
117
|
// `undefined` — which passes the `!== false` test and then fetches nothing.
|
|
104
118
|
for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
|
|
105
119
|
if (oneFetch.prerender === false) continue
|
|
106
|
-
const cfg =
|
|
107
|
-
const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
|
|
120
|
+
const cfg = resolveForBuild(oneFetch)
|
|
108
121
|
onProgress(` Fetching site data: ${cfg.path || cfg.url}`)
|
|
109
|
-
const result = await executeFetch(cfg,
|
|
122
|
+
const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
|
|
110
123
|
if (result.data && !result.error) {
|
|
111
|
-
fetchedData.push(
|
|
124
|
+
fetchedData.push(entry(cfg, result.data, '__site__'))
|
|
112
125
|
}
|
|
113
126
|
}
|
|
114
127
|
|
|
@@ -119,12 +132,11 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
|
|
|
119
132
|
// Page-level fetch — every declaration on the page.
|
|
120
133
|
for (const oneFetch of toFetchList(page.fetch)) {
|
|
121
134
|
if (oneFetch.prerender === false) continue
|
|
122
|
-
const cfg =
|
|
123
|
-
const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
|
|
135
|
+
const cfg = resolveForBuild(oneFetch)
|
|
124
136
|
onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
|
|
125
|
-
const result = await executeFetch(cfg,
|
|
137
|
+
const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
|
|
126
138
|
if (result.data && !result.error) {
|
|
127
|
-
fetchedData.push(
|
|
139
|
+
fetchedData.push(entry(cfg, result.data, page.route))
|
|
128
140
|
// ⚖️ Dynamic-route expansion consumes ONE query — a `[slug]` template
|
|
129
141
|
// expands over a single record set. With several declared, the first
|
|
130
142
|
// that prerenders is the route query, matching `parentSchema` in the
|
|
@@ -190,7 +202,8 @@ export function localizeRedirectTarget(target, { website, locale, isDefault, rou
|
|
|
190
202
|
return (website.basePath || '') + withSlash
|
|
191
203
|
}
|
|
192
204
|
|
|
193
|
-
export function expandDynamicPages(pages, pageFetchedData, onProgress) {
|
|
205
|
+
export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}, stats = { unrouted: {} }) {
|
|
206
|
+
if (!stats.unrouted) stats.unrouted = {}
|
|
194
207
|
const expandedPages = []
|
|
195
208
|
|
|
196
209
|
// Static pages win over the dynamic `[slug]` catch-all, matching the SPA's
|
|
@@ -221,8 +234,9 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
|
|
|
221
234
|
}
|
|
222
235
|
|
|
223
236
|
// Find the parent's data
|
|
224
|
-
// The parent route is the route without the :param suffix
|
|
225
|
-
const
|
|
237
|
+
// The parent route is the route without the :param (or :path*) suffix
|
|
238
|
+
const catchAll = /\/:path\*$/.test(page.route)
|
|
239
|
+
const parentRoute = page.route.replace(/\/:[\w]+\*?$/, '') || '/'
|
|
226
240
|
const parentData = pageFetchedData.get(parentRoute)
|
|
227
241
|
|
|
228
242
|
if (!parentData || !Array.isArray(parentData.data)) {
|
|
@@ -238,17 +252,30 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
|
|
|
238
252
|
|
|
239
253
|
onProgress(` Expanding ${page.route} → ${items.length} pages from ${schema}`)
|
|
240
254
|
|
|
255
|
+
// ⛔ COUNTED, not only logged per record. A record with no value for the
|
|
256
|
+
// route's param gets no page — correct — but "Skipping item without slug"
|
|
257
|
+
// once per record is a line nobody reads: an author who files twenty
|
|
258
|
+
// records and names three gets three pages and no idea why. The total is
|
|
259
|
+
// said once at the end, and handed back on `stats` for a caller to assert.
|
|
260
|
+
let unrouted = 0
|
|
261
|
+
|
|
241
262
|
// Create a concrete page for each item
|
|
242
263
|
for (const item of items) {
|
|
243
264
|
// Get the param value from the item (e.g., item.slug for :slug)
|
|
244
265
|
const paramValue = item[paramName]
|
|
245
266
|
if (!paramValue) {
|
|
246
|
-
|
|
267
|
+
unrouted += 1
|
|
247
268
|
continue
|
|
248
269
|
}
|
|
249
270
|
|
|
250
|
-
// Create concrete route: /blog/:slug → /blog/my-post
|
|
251
|
-
|
|
271
|
+
// Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
|
|
272
|
+
// record's URL is its placement (the folder `records.yml` put it in, carried
|
|
273
|
+
// as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
|
|
274
|
+
// decoded: the server decodes the request before looking the file up.
|
|
275
|
+
const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
|
|
276
|
+
const concreteRoute = catchAll
|
|
277
|
+
? page.route.replace(/:path\*$/, capture)
|
|
278
|
+
: page.route.replace(`:${paramName}`, paramValue)
|
|
252
279
|
|
|
253
280
|
// Static sibling wins: skip a record whose concrete route collides with
|
|
254
281
|
// an existing static page rather than overwriting its HTML at write time.
|
|
@@ -277,6 +304,9 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
|
|
|
277
304
|
paramName,
|
|
278
305
|
paramValue,
|
|
279
306
|
schema, // Plural: 'articles'
|
|
307
|
+
// A catch-all page carries its three variables, so a query binding
|
|
308
|
+
// `:dir` or `:path` resolves the same way it does in the browser.
|
|
309
|
+
...(catchAll ? { params: splitPathCapture(capture) } : {}),
|
|
280
310
|
}
|
|
281
311
|
|
|
282
312
|
// Use item data for page metadata if available
|
|
@@ -285,6 +315,14 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
|
|
|
285
315
|
|
|
286
316
|
expandedPages.push(concretePage)
|
|
287
317
|
}
|
|
318
|
+
|
|
319
|
+
if (unrouted > 0) {
|
|
320
|
+
stats.unrouted[page.route] = unrouted
|
|
321
|
+
onProgress(
|
|
322
|
+
` ⚠️ ${unrouted} of ${items.length} ${schema} records have no "${paramName}" — no page was ` +
|
|
323
|
+
`generated for them under ${page.route}`
|
|
324
|
+
)
|
|
325
|
+
}
|
|
288
326
|
}
|
|
289
327
|
|
|
290
328
|
return expandedPages
|
|
@@ -626,7 +664,7 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
626
664
|
onProgress(`\nRendering ${isDefault ? 'default' : locale} locale...`)
|
|
627
665
|
|
|
628
666
|
// Load locale-specific content
|
|
629
|
-
|
|
667
|
+
let siteContent = JSON.parse(await readFile(localeContentPath, 'utf8'))
|
|
630
668
|
|
|
631
669
|
// Set the active locale in the content
|
|
632
670
|
siteContent.config = siteContent.config || {}
|
|
@@ -644,6 +682,11 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
644
682
|
// Store fetchedData on siteContent for runtime DataStore pre-population
|
|
645
683
|
siteContent.fetchedData = fetchedData
|
|
646
684
|
|
|
685
|
+
// The build has consumed every build-only fetch key by now (`merge`, read
|
|
686
|
+
// by the section fetches above); what ships in `__SITE_CONTENT__` is the
|
|
687
|
+
// runtime's payload and carries none of them.
|
|
688
|
+
siteContent = stripBuildOnlyFetchKeys(siteContent)
|
|
689
|
+
|
|
647
690
|
// Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
|
|
648
691
|
if (siteContent.pages?.some(p => p.isDynamic)) {
|
|
649
692
|
onProgress('Expanding dynamic routes...')
|
package/src/runtime-schema.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { isRichSchema } from '@uniweb/core'
|
|
31
|
+
import { flatRecordFields } from '@uniweb/schemas/conform'
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Parse data string into structured object
|
|
@@ -211,8 +212,22 @@ function leanDataSchema(value, dataSchemaMap) {
|
|
|
211
212
|
: (value && typeof value.schema === 'string' ? value.schema : null)
|
|
212
213
|
if (ref) {
|
|
213
214
|
const resolved = dataSchemaMap[ref]
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
// ⛔ `flatRecordFields`, NOT `resolved.fields`. `dataSchemaMap` holds each
|
|
216
|
+
// schema AS AUTHORED — resolution and lowering are different steps, and only
|
|
217
|
+
// the second normalizes the two authoring forms. `fields:` at the top is the
|
|
218
|
+
// SUGAR for a one-section model; a sections-form schema has no such key, so
|
|
219
|
+
// reading it directly returned null for every `@std/*` binding and the
|
|
220
|
+
// section's `data:` declaration supplied no field defaults at all. Silent:
|
|
221
|
+
// the data still arrived, just without its defaults.
|
|
222
|
+
//
|
|
223
|
+
// This helper is the reader that takes both forms (`if (schema.fields)
|
|
224
|
+
// return schema.fields`, else walk the single sections), and
|
|
225
|
+
// `site/queries-config.js` already used it for exactly this reason.
|
|
226
|
+
// Measured 2026-09-03: `@std/article` → undefined here, 15 fields through
|
|
227
|
+
// the helper; `@/member` (sugar) → 4 either way.
|
|
228
|
+
const fields = flatRecordFields(resolved)
|
|
229
|
+
if (!fields) return null
|
|
230
|
+
const lean = extractSchemaFields(fields)
|
|
216
231
|
return Object.keys(lean).length > 0 ? lean : null
|
|
217
232
|
}
|
|
218
233
|
|
package/src/schema.js
CHANGED
|
@@ -67,6 +67,74 @@ export async function loadComponentMeta(componentDir) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Normalize `package.json`'s `uniweb.supports` — the host services this
|
|
72
|
+
* foundation is BUILT AGAINST.
|
|
73
|
+
*
|
|
74
|
+
* A foundation states what it is prepared to honour; a host states what it
|
|
75
|
+
* offers (`config.services.<name>`, read by `@uniweb/core/services`). The two
|
|
76
|
+
* declarations point in opposite directions across the same seam, which is why
|
|
77
|
+
* this one is spelled `supports` and never `services`.
|
|
78
|
+
*
|
|
79
|
+
* ## ⭐ THREE STATES, AND COLLAPSING THE FIRST TWO IS THE WHOLE POINT
|
|
80
|
+
*
|
|
81
|
+
* absent UNKNOWN — an older CLI, or a developer who never met this key
|
|
82
|
+
* `[]` an explicit NONE
|
|
83
|
+
* `['search']` these, and only these
|
|
84
|
+
*
|
|
85
|
+
* Same lift as `info.runtime` (`uwx/registry-package.js::buildInfo`), whose
|
|
86
|
+
* comment states the rule this follows: *"a consumer must read the omission as
|
|
87
|
+
* UNKNOWN rather than unconstrained, since a floor nobody stated cannot be shown
|
|
88
|
+
* to be satisfied."* An unstated support set is not a refusal, and a reader that
|
|
89
|
+
* treats it as one blocks every foundation built before this existed.
|
|
90
|
+
*
|
|
91
|
+
* ⛔ **So this returns `{}` for absent and `{ supports: [] }` for empty**, and
|
|
92
|
+
* they must not be flattened downstream. A default of any kind here — assuming
|
|
93
|
+
* search is present because most foundations have one — would claim a service on
|
|
94
|
+
* behalf of the developer who never heard of the feature, which is the same
|
|
95
|
+
* person as the developer who forgot to declare it. That trades a loud failure
|
|
96
|
+
* (a capability reported unknown) for a silent one (an operator paying for
|
|
97
|
+
* something their site will not render).
|
|
98
|
+
*
|
|
99
|
+
* ⚖️ Whether an unknown service is still worth offering is a POLICY question
|
|
100
|
+
* about that service, and policy on this seam is not the framework's: this
|
|
101
|
+
* package is public, and it encodes no tiers, no plan names and no entitlement
|
|
102
|
+
* (`core/src/services.js`). We report faithfully, including reporting that we do
|
|
103
|
+
* not know.
|
|
104
|
+
*
|
|
105
|
+
* Sorted and de-duplicated so a re-export of one foundation version is
|
|
106
|
+
* byte-identical — the same reason `buildRefs` sorts.
|
|
107
|
+
*
|
|
108
|
+
* @param {*} value - the raw `uniweb.supports` value, if any
|
|
109
|
+
* @param {string} packagePath - for the warning message
|
|
110
|
+
* @returns {{supports?: string[]}} `{}` when undeclared
|
|
111
|
+
*/
|
|
112
|
+
function normalizeSupports(value, packagePath) {
|
|
113
|
+
if (value === undefined || value === null) return {}
|
|
114
|
+
|
|
115
|
+
if (!Array.isArray(value)) {
|
|
116
|
+
console.warn(
|
|
117
|
+
`Warning: ${packagePath} declares \`uniweb.supports\` as ${typeof value}; expected an array of service names. Ignoring it.\n` +
|
|
118
|
+
` An ignored declaration reads downstream as UNKNOWN, not as "supports nothing".`,
|
|
119
|
+
)
|
|
120
|
+
return {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const names = []
|
|
124
|
+
const dropped = []
|
|
125
|
+
for (const entry of value) {
|
|
126
|
+
if (typeof entry === 'string' && entry.trim()) names.push(entry.trim())
|
|
127
|
+
else dropped.push(entry)
|
|
128
|
+
}
|
|
129
|
+
if (dropped.length > 0) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`Warning: ${packagePath} has ${dropped.length} non-name entr${dropped.length === 1 ? 'y' : 'ies'} in \`uniweb.supports\`. Ignoring ${dropped.length === 1 ? 'it' : 'them'}.`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { supports: [...new Set(names)].sort() }
|
|
136
|
+
}
|
|
137
|
+
|
|
70
138
|
/**
|
|
71
139
|
* Load package.json from a foundation's root.
|
|
72
140
|
* Extracts identity fields: name, version, description.
|
|
@@ -104,6 +172,11 @@ export async function loadPackageJson(srcDir) {
|
|
|
104
172
|
name: pkg.uniweb?.id || pkg.name,
|
|
105
173
|
version: pkg.version,
|
|
106
174
|
description: pkg.description,
|
|
175
|
+
// `uniweb.supports` joins them because it is the same KIND of fact: static,
|
|
176
|
+
// publish-time, read by whoever resolves a whole site, never executed here.
|
|
177
|
+
// Spread rather than assigned so an absent declaration stays ABSENT — see
|
|
178
|
+
// normalizeSupports for why that is load-bearing.
|
|
179
|
+
...normalizeSupports(pkg.uniweb?.supports, packagePath),
|
|
107
180
|
}
|
|
108
181
|
} catch (error) {
|
|
109
182
|
console.warn(`Warning: Failed to load package.json:`, error.message)
|
|
@@ -24,6 +24,7 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
24
24
|
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
26
|
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
27
|
+
import { stripBuildOnlyFetchKeys } from './data-fetcher.js'
|
|
27
28
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
28
29
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
29
30
|
import {
|
|
@@ -235,6 +236,10 @@ export async function buildSiteData({
|
|
|
235
236
|
finalContent = { ...finalContent, config: configWithoutIcons }
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
// Build-only fetch keys (`merge`) never reach a shipped payload; this lane runs
|
|
240
|
+
// no prerender, so nothing here reads them either.
|
|
241
|
+
finalContent = stripBuildOnlyFetchKeys(finalContent)
|
|
242
|
+
|
|
238
243
|
// 4. Write `dist/site-content.json` with FULL sections inlined.
|
|
239
244
|
// Important: do NOT strip sections per the split-content rule here.
|
|
240
245
|
// Stripping would silently break split-mode sites on any consumer that
|
|
@@ -57,20 +57,69 @@ try {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
60
|
+
* The multi-segment route folder — ONE fixed spelling, ruled 2026-09-04 [Diego].
|
|
61
|
+
* `[...slug]`, `[...rest]` and a bare `[...]` are NOT the marker: an author does
|
|
62
|
+
* not name how the URL is parsed. The capture is split by the runtime into the
|
|
63
|
+
* standard `:path` / `:dir` / `:slug` variables (`@uniweb/core/route-match`).
|
|
64
|
+
*/
|
|
65
|
+
const CATCH_ALL_FOLDER = '[...path]'
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The site-level `fetcher:` vocabulary the default fetcher used to read —
|
|
69
|
+
* `baseUrl`, `headers`, `envelope`, `supports`, `request` — was RETIRED on
|
|
70
|
+
* 2026-09-04 [Diego]: a third-party endpoint is a foundation TRANSPORT, never a
|
|
71
|
+
* knob on the runtime every site loads. What stays under `fetcher:` is the
|
|
72
|
+
* site's SELECTION of transports (`transports:`) and the binding config a named
|
|
73
|
+
* transport reads (`fetcher.<name>:`). A retired key is dropped from the payload
|
|
74
|
+
* and said out loud, once, because silently ignoring it is the failure class
|
|
75
|
+
* this seam exists to remove: the author's backend simply stops being reached.
|
|
76
|
+
*/
|
|
77
|
+
const RETIRED_FETCHER_KEYS = ['baseUrl', 'headers', 'envelope', 'supports', 'request']
|
|
78
|
+
let warnedRetiredFetcher = false
|
|
79
|
+
function warnRetiredFetcherKeys(fetcher) {
|
|
80
|
+
if (!fetcher || typeof fetcher !== 'object' || Array.isArray(fetcher)) return fetcher
|
|
81
|
+
const retired = RETIRED_FETCHER_KEYS.filter((k) => k in fetcher)
|
|
82
|
+
if (retired.length === 0) return fetcher
|
|
83
|
+
if (!warnedRetiredFetcher) {
|
|
84
|
+
warnedRetiredFetcher = true
|
|
85
|
+
console.warn(
|
|
86
|
+
`[uniweb] site.yml fetcher: ${retired.map((k) => `\`${k}\``).join(', ')} ` +
|
|
87
|
+
`${retired.length === 1 ? 'is' : 'are'} retired and ignored. The default fetcher reads a ` +
|
|
88
|
+
`site's own files and a host's records lane; a backend of your own is reached through a ` +
|
|
89
|
+
`foundation transport (docs: development/connecting-a-backend.md). Kept: ` +
|
|
90
|
+
`\`fetcher.transports\` and a transport's own binding config.`
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
const kept = {}
|
|
94
|
+
for (const [k, v] of Object.entries(fetcher)) if (!RETIRED_FETCHER_KEYS.includes(k)) kept[k] = v
|
|
95
|
+
return kept
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a folder name represents a dynamic route (e.g., [slug], [id], [...path])
|
|
61
100
|
* @param {string} folderName - The folder name to check
|
|
62
101
|
* @returns {boolean}
|
|
63
102
|
*/
|
|
64
103
|
function isDynamicRoute(folderName) {
|
|
65
|
-
return /^\[(\w+)\]$/.test(folderName)
|
|
104
|
+
return folderName === CATCH_ALL_FOLDER || /^\[(\w+)\]$/.test(folderName)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Is this the multi-segment route folder? */
|
|
108
|
+
function isCatchAllRoute(folderName) {
|
|
109
|
+
return folderName === CATCH_ALL_FOLDER
|
|
66
110
|
}
|
|
67
111
|
|
|
68
112
|
/**
|
|
69
|
-
* Extract the parameter name from a dynamic route folder (e.g., [slug] → slug)
|
|
113
|
+
* Extract the parameter name from a dynamic route folder (e.g., [slug] → slug).
|
|
114
|
+
*
|
|
115
|
+
* `[...path]` yields `slug`: the record is delivered by its handle — the LAST
|
|
116
|
+
* segment of the capture — exactly as under `[slug]`, so a query written for
|
|
117
|
+
* one route kind behaves the same under the other.
|
|
70
118
|
* @param {string} folderName - The folder name (e.g., "[slug]")
|
|
71
119
|
* @returns {string|null} The parameter name or null if not a dynamic route
|
|
72
120
|
*/
|
|
73
121
|
function extractRouteParam(folderName) {
|
|
122
|
+
if (isCatchAllRoute(folderName)) return 'slug'
|
|
74
123
|
const match = folderName.match(/^\[(\w+)\]$/)
|
|
75
124
|
return match ? match[1] : null
|
|
76
125
|
}
|
|
@@ -1389,8 +1438,10 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1389
1438
|
// First, calculate the folder-based route (what the route would be without index handling)
|
|
1390
1439
|
let folderRoute
|
|
1391
1440
|
if (isDynamic) {
|
|
1392
|
-
// Dynamic routes: /blog/[slug] → /blog/:slug (for route matching)
|
|
1393
|
-
|
|
1441
|
+
// Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
|
|
1442
|
+
// /blog/[...path] → /blog/:path* — the one multi-segment token the matcher knows.
|
|
1443
|
+
const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
|
|
1444
|
+
folderRoute = parentRoute === '/' ? `/${token}` : `${parentRoute}/${token}`
|
|
1394
1445
|
} else {
|
|
1395
1446
|
// Normal pages get parent + their name
|
|
1396
1447
|
folderRoute = parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
|
|
@@ -2056,8 +2107,8 @@ export async function loadFoundationInfo(foundationPath) {
|
|
|
2056
2107
|
// rich per-section declaration a visual editor needs to render parameter forms
|
|
2057
2108
|
// and component pickers — and it is not a source of truth for a site build.
|
|
2058
2109
|
// The architecture is explicit that the declaration is emitted in two shapes
|
|
2059
|
-
// for two audiences (
|
|
2060
|
-
//
|
|
2110
|
+
// for two audiences (the site / foundation / runtime model, § The two-audience
|
|
2111
|
+
// schema): the lean runtime half ships INSIDE `dist/entry.js`
|
|
2061
2112
|
// as `capabilities`, and the rich half is for authoring tools only.
|
|
2062
2113
|
//
|
|
2063
2114
|
// Reading the editor's copy here was wrong twice over. It made a site build
|
|
@@ -2563,6 +2614,7 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2563
2614
|
? { languages: publishable }
|
|
2564
2615
|
: {}),
|
|
2565
2616
|
fetch: parseFetchConfig(siteConfig.fetch),
|
|
2617
|
+
fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
|
|
2566
2618
|
// NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
|
|
2567
2619
|
// Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
|
|
2568
2620
|
// needs no line at all on this lane (`config` spreads all of site.yml) and one
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
20
20
|
import { join } from 'node:path'
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
22
|
import yaml from 'js-yaml'
|
|
23
|
-
import { matchWhere, queryDataUrl } from '@uniweb/core'
|
|
23
|
+
import { matchWhere, sortRecords, queryDataUrl } from '@uniweb/core'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Infer schema name from path or URL
|
|
@@ -128,42 +128,31 @@ export function applyFilter(items, filterExpr) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* Apply sort
|
|
131
|
+
* Apply a `sort:` to an array of items — `@uniweb/core`'s ONE evaluator, the
|
|
132
|
+
* same the runtime's fallback runs, so a query orders identically on the file
|
|
133
|
+
* lane and over a fetched array.
|
|
134
|
+
*
|
|
135
|
+
* ⛔ SINGLE-KEY, BY RULING [Diego, 2026-09-04]. This was its own implementation
|
|
136
|
+
* until then, and it honoured `order asc, title asc` — a multi-key sort the
|
|
137
|
+
* records door refuses and the ruling dropped. A comma now THROWS here, at build
|
|
138
|
+
* time, which is where an authoring error on the file lane belongs.
|
|
132
139
|
*
|
|
133
140
|
* @param {Array} items - Items to sort
|
|
134
|
-
* @param {string} sortExpr - Sort expression
|
|
141
|
+
* @param {string} sortExpr - Sort expression: `date`, `date desc`, `-date`
|
|
135
142
|
* @returns {Array} Sorted items (new array)
|
|
136
|
-
*
|
|
137
|
-
* @example
|
|
138
|
-
* applySort(items, 'date desc')
|
|
139
|
-
* applySort(items, 'order asc, title asc')
|
|
140
143
|
*/
|
|
141
144
|
export function applySort(items, sortExpr) {
|
|
142
145
|
if (!sortExpr || !Array.isArray(items)) return items
|
|
143
|
-
|
|
144
|
-
const sorts = sortExpr.split(',').map(s => {
|
|
145
|
-
const [field, dir = 'asc'] = s.trim().split(/\s+/)
|
|
146
|
-
return { field, desc: dir.toLowerCase() === 'desc' }
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
return [...items].sort((a, b) => {
|
|
150
|
-
for (const { field, desc } of sorts) {
|
|
151
|
-
const aVal = getNestedValue(a, field) ?? ''
|
|
152
|
-
const bVal = getNestedValue(b, field) ?? ''
|
|
153
|
-
if (aVal < bVal) return desc ? 1 : -1
|
|
154
|
-
if (aVal > bVal) return desc ? -1 : 1
|
|
155
|
-
}
|
|
156
|
-
return 0
|
|
157
|
-
})
|
|
146
|
+
return sortRecords(items, sortExpr)
|
|
158
147
|
}
|
|
159
148
|
|
|
160
149
|
/**
|
|
161
150
|
* Apply a where-object predicate to an array of items.
|
|
162
151
|
*
|
|
163
|
-
* The where-object is the
|
|
164
|
-
* matchWhere). Structured JSON predicate; the
|
|
165
|
-
*
|
|
166
|
-
*
|
|
152
|
+
* The where-object is the query language (see @uniweb/core's
|
|
153
|
+
* matchWhere). Structured JSON predicate; the one evaluator walks the
|
|
154
|
+
* object against each record, here at build time and in the runtime
|
|
155
|
+
* alike. The same shape crosses to a host's question door unchanged.
|
|
167
156
|
*
|
|
168
157
|
* @param {Array} items - Items to filter
|
|
169
158
|
* @param {object} where - Where-object predicate
|
|
@@ -252,22 +241,39 @@ export function applyPostProcessing(data, config) {
|
|
|
252
241
|
// once per key name per process so a 200-record build does not print 200 lines.
|
|
253
242
|
const RECOGNIZED_FETCH_KEYS = {
|
|
254
243
|
refine: new Set(['refine', 'detail', 'limit', 'sort', 'where', 'filter']),
|
|
244
|
+
// ⛔ `schema` IS NOT ON EITHER LIST, and its absence is the point. It was the
|
|
245
|
+
// binding key until 2026-09-02 and stopped being READ on 2026-09-03 (`e4fe077`,
|
|
246
|
+
// one name no alias) — but it was left on these lists, which exempted it from
|
|
247
|
+
// the very report this table exists to produce. So the retired spelling was
|
|
248
|
+
// dropped in the one way the author could not see: no warning, and a plausible
|
|
249
|
+
// key inferred from the path in its place. It has its own message below, since
|
|
250
|
+
// "unrecognized" understates a key that used to work.
|
|
255
251
|
query: new Set([
|
|
256
|
-
'query', 'as', '
|
|
252
|
+
'query', 'as', 'prerender', 'merge', 'transform',
|
|
257
253
|
'where', 'limit', 'sort', 'detailPage', 'filter',
|
|
258
254
|
]),
|
|
259
255
|
source: new Set([
|
|
260
|
-
'path', 'url', 'as', '
|
|
256
|
+
'path', 'url', 'as', 'prerender', 'merge', 'transform', 'detail',
|
|
261
257
|
'detailPage', 'where', 'limit', 'sort', 'filter',
|
|
262
258
|
]),
|
|
263
259
|
}
|
|
264
260
|
|
|
261
|
+
// Keys that are neither recognized nor merely unknown: they USED to work, and a
|
|
262
|
+
// generic "unrecognized key" line understates that. Each has a dedicated message
|
|
263
|
+
// naming its replacement, so this table only has to keep the generic report from
|
|
264
|
+
// firing a second, vaguer time on the same key.
|
|
265
|
+
//
|
|
266
|
+
// ⛔ This is not the recognized list wearing another name. A key here is still
|
|
267
|
+
// dropped from the parsed config; what it buys is a better sentence.
|
|
268
|
+
const RETIRED_FETCH_KEYS = new Set(['schema'])
|
|
269
|
+
|
|
265
270
|
const warnedUnknownFetchKeys = new Set()
|
|
266
271
|
|
|
267
272
|
function warnUnknownFetchKeys(fetch, shape) {
|
|
268
273
|
const recognized = RECOGNIZED_FETCH_KEYS[shape]
|
|
269
274
|
for (const key of Object.keys(fetch)) {
|
|
270
275
|
if (recognized.has(key)) continue
|
|
276
|
+
if (RETIRED_FETCH_KEYS.has(key)) continue
|
|
271
277
|
const seenKey = `${shape}:${key}`
|
|
272
278
|
if (warnedUnknownFetchKeys.has(seenKey)) continue
|
|
273
279
|
warnedUnknownFetchKeys.add(seenKey)
|
|
@@ -406,6 +412,7 @@ export function parseFetchConfig(fetch) {
|
|
|
406
412
|
if (fetch.query) {
|
|
407
413
|
warnUnknownFetchKeys(fetch, 'query')
|
|
408
414
|
if (fetch.filter !== undefined) warnFilterDeprecated()
|
|
415
|
+
warnSchemaRetired(fetch, fetch.as || fetch.query)
|
|
409
416
|
return {
|
|
410
417
|
// ⭐ **`query` IS EMITTED, and that is what makes the two producers agree.**
|
|
411
418
|
// The sync lane has always emitted it (`uwx/site.js`) and this one did not,
|
|
@@ -470,6 +477,7 @@ export function parseFetchConfig(fetch) {
|
|
|
470
477
|
if (!path && !url) return null
|
|
471
478
|
|
|
472
479
|
if (filter !== undefined) warnFilterDeprecated()
|
|
480
|
+
warnSchemaRetired(fetch, as ?? inferSchemaFromPath(path || url))
|
|
473
481
|
|
|
474
482
|
return {
|
|
475
483
|
path,
|
|
@@ -493,6 +501,46 @@ export function parseFetchConfig(fetch) {
|
|
|
493
501
|
}
|
|
494
502
|
}
|
|
495
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Report a fetch still authored with the retired `schema:` binding key.
|
|
506
|
+
*
|
|
507
|
+
* ⭐ **It names the key the fetch ACTUALLY bound to, and that is the whole
|
|
508
|
+
* value of this message.** `schema:` is not read (ruling 2026-09-03, `e4fe077`):
|
|
509
|
+
* the binding key falls back to the query name or to `inferSchemaFromPath`, so
|
|
510
|
+
* the data still arrives — under a *different* `content.data` key. The component
|
|
511
|
+
* reads `?.weather`, gets `undefined`, and renders empty with nothing anywhere
|
|
512
|
+
* saying why. A bare "unrecognized key" would not close that gap; the inferred
|
|
513
|
+
* name does, because the reader can see at once whether it happens to match.
|
|
514
|
+
*
|
|
515
|
+
* ⚠️ Measured 2026-09-03, `templates/dynamic`: five of six sections rendered
|
|
516
|
+
* empty this way, one of them from a URL whose last segment is empty
|
|
517
|
+
* (`randomuser.me/api/?results=6` → `as: ''`), which is falsy and drops the
|
|
518
|
+
* config outright. That template shipped with no warning of any kind, because
|
|
519
|
+
* `schema` was left on the recognized list when it stopped being read.
|
|
520
|
+
*
|
|
521
|
+
* Once per distinct (written → bound) pair: several files each get their own
|
|
522
|
+
* line, one file repeated across 200 records does not.
|
|
523
|
+
*/
|
|
524
|
+
const warnedRetiredSchema = new Set()
|
|
525
|
+
function warnSchemaRetired(fetch, boundTo) {
|
|
526
|
+
if (fetch?.schema === undefined) return
|
|
527
|
+
const wrote = String(fetch.schema)
|
|
528
|
+
const bound = boundTo === '' || boundTo === undefined ? '(nothing)' : String(boundTo)
|
|
529
|
+
const seen = `${wrote}→${bound}`
|
|
530
|
+
if (warnedRetiredSchema.has(seen)) return
|
|
531
|
+
warnedRetiredSchema.add(seen)
|
|
532
|
+
console.warn(
|
|
533
|
+
`[uniweb] fetch: 'schema: ${wrote}' is retired as the binding key and is NOT read. ` +
|
|
534
|
+
`This fetch binds to content.data.${bound} instead. Write 'as: ${wrote}'. ` +
|
|
535
|
+
"(On a `queries:` declaration `schema:` is a different, current key — the Model ref.)"
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Test seam — reset the retired-`schema:` memo so suites do not leak into each other. */
|
|
540
|
+
export function _resetRetiredSchemaWarnings() {
|
|
541
|
+
warnedRetiredSchema.clear()
|
|
542
|
+
}
|
|
543
|
+
|
|
496
544
|
let filterDeprecationWarned = false
|
|
497
545
|
function warnFilterDeprecated() {
|
|
498
546
|
if (filterDeprecationWarned) return
|
|
@@ -505,6 +553,98 @@ function warnFilterDeprecated() {
|
|
|
505
553
|
)
|
|
506
554
|
}
|
|
507
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Keys a fetch declaration carries for THE BUILD ONLY, which no runtime reads.
|
|
558
|
+
*
|
|
559
|
+
* `merge` decides how a section-level fetch lands in `parsedContent.data` when
|
|
560
|
+
* prerender (or the dev server) executes it — a build-lane feature, documented as
|
|
561
|
+
* such. It rode every shipped payload regardless, and a key on the payload that
|
|
562
|
+
* nothing reads is a key a consumer will one day read. Stripped at the two emit points framework owns — the link lane's
|
|
563
|
+
* `site-content.json` and the bundle lane's embed — AFTER the build has consumed
|
|
564
|
+
* it. ⛔ Not from the sync wire: that carries the author's declaration, which
|
|
565
|
+
* `pull` must round-trip.
|
|
566
|
+
*/
|
|
567
|
+
const BUILD_ONLY_FETCH_KEYS = ['merge']
|
|
568
|
+
|
|
569
|
+
function stripFetch(fetch) {
|
|
570
|
+
if (!fetch || typeof fetch !== 'object') return fetch
|
|
571
|
+
if (Array.isArray(fetch)) return fetch.map(stripFetch)
|
|
572
|
+
let changed = false
|
|
573
|
+
const out = {}
|
|
574
|
+
for (const [key, value] of Object.entries(fetch)) {
|
|
575
|
+
if (BUILD_ONLY_FETCH_KEYS.includes(key)) {
|
|
576
|
+
changed = true
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
out[key] = value
|
|
580
|
+
}
|
|
581
|
+
return changed ? out : fetch
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function stripSections(sections) {
|
|
585
|
+
if (!Array.isArray(sections)) return sections
|
|
586
|
+
return sections.map((section) => {
|
|
587
|
+
if (!section || typeof section !== 'object') return section
|
|
588
|
+
const fetch = stripFetch(section.fetch)
|
|
589
|
+
const subsections = stripSections(section.subsections)
|
|
590
|
+
if (fetch === section.fetch && subsections === section.subsections) return section
|
|
591
|
+
const out = { ...section }
|
|
592
|
+
if (fetch !== section.fetch) out.fetch = fetch
|
|
593
|
+
if (subsections !== section.subsections) out.subsections = subsections
|
|
594
|
+
return out
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function stripPageLike(page) {
|
|
599
|
+
if (!page || typeof page !== 'object') return page
|
|
600
|
+
const fetch = stripFetch(page.fetch)
|
|
601
|
+
const sections = stripSections(page.sections)
|
|
602
|
+
if (fetch === page.fetch && sections === page.sections) return page
|
|
603
|
+
const out = { ...page }
|
|
604
|
+
if (fetch !== page.fetch) out.fetch = fetch
|
|
605
|
+
if (sections !== page.sections) out.sections = sections
|
|
606
|
+
return out
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* A copy of a site-content payload with the build-only fetch keys removed from
|
|
611
|
+
* every fetch declaration it carries: `config.fetch`, each page's, each
|
|
612
|
+
* section's (and subsection's), each layout area's, and the `config` inside
|
|
613
|
+
* `fetchedData` entries. Structural sharing — untouched objects are the same
|
|
614
|
+
* objects, so this is cheap on a large site.
|
|
615
|
+
*
|
|
616
|
+
* @param {Object} siteContent
|
|
617
|
+
* @returns {Object}
|
|
618
|
+
*/
|
|
619
|
+
export function stripBuildOnlyFetchKeys(siteContent) {
|
|
620
|
+
if (!siteContent || typeof siteContent !== 'object') return siteContent
|
|
621
|
+
const out = { ...siteContent }
|
|
622
|
+
if (out.config && typeof out.config === 'object' && out.config.fetch !== undefined) {
|
|
623
|
+
const fetch = stripFetch(out.config.fetch)
|
|
624
|
+
if (fetch !== out.config.fetch) out.config = { ...out.config, fetch }
|
|
625
|
+
}
|
|
626
|
+
if (Array.isArray(out.pages)) out.pages = out.pages.map(stripPageLike)
|
|
627
|
+
if (out.layouts && typeof out.layouts === 'object') {
|
|
628
|
+
const layouts = {}
|
|
629
|
+
for (const [name, areas] of Object.entries(out.layouts)) {
|
|
630
|
+
if (!areas || typeof areas !== 'object') { layouts[name] = areas; continue }
|
|
631
|
+
const next = {}
|
|
632
|
+
for (const [area, page] of Object.entries(areas)) next[area] = stripPageLike(page)
|
|
633
|
+
layouts[name] = next
|
|
634
|
+
}
|
|
635
|
+
out.layouts = layouts
|
|
636
|
+
}
|
|
637
|
+
if (out.notFound) out.notFound = stripPageLike(out.notFound)
|
|
638
|
+
if (Array.isArray(out.fetchedData)) {
|
|
639
|
+
out.fetchedData = out.fetchedData.map((entry) => {
|
|
640
|
+
if (!entry || typeof entry !== 'object') return entry
|
|
641
|
+
const config = stripFetch(entry.config)
|
|
642
|
+
return config === entry.config ? entry : { ...entry, config }
|
|
643
|
+
})
|
|
644
|
+
}
|
|
645
|
+
return out
|
|
646
|
+
}
|
|
647
|
+
|
|
508
648
|
/**
|
|
509
649
|
* Execute a fetch operation
|
|
510
650
|
*
|
package/src/site/plugin.js
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import { resolve, join } from 'node:path'
|
|
34
34
|
import { watch, existsSync } from 'node:fs'
|
|
35
35
|
import { readFile, readdir } from 'node:fs/promises'
|
|
36
|
-
import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
|
|
36
|
+
import { resolveDefaultLocale, resolveFetchConfigs, DATA_DIR } from '@uniweb/core'
|
|
37
37
|
import {
|
|
38
38
|
renderSiteIndex,
|
|
39
39
|
renderPageMarkdown,
|
|
@@ -136,13 +136,24 @@ export function shouldPrefetchInDev(cfg) {
|
|
|
136
136
|
async function executeDevFetches(siteContent, siteDir) {
|
|
137
137
|
const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
|
|
138
138
|
const fetchedData = []
|
|
139
|
+
// Resolved the way the runtime resolves it (see prerender.js::executeAllFetches
|
|
140
|
+
// for why): the SPA hydrates by the cache key of ITS resolved config.
|
|
141
|
+
const resolveOptions = {
|
|
142
|
+
locale: siteContent.config?.activeLocale ?? null,
|
|
143
|
+
defaultLocale: resolveDefaultLocale(siteContent.config) ?? null,
|
|
144
|
+
queries: siteContent.config?.queries ?? null,
|
|
145
|
+
records: null,
|
|
146
|
+
}
|
|
147
|
+
const resolveForDev = (one) => resolveFetchConfigs([one], resolveOptions).get(one.as) ?? one
|
|
148
|
+
const entry = (cfg, data) => ({ config: cfg, data, meta: { depth: cfg.depth } })
|
|
139
149
|
|
|
140
150
|
// Site-level fetch — every declaration.
|
|
141
151
|
for (const siteFetch of toFetchList(siteContent.config?.fetch)) {
|
|
142
152
|
if (!shouldPrefetchInDev(siteFetch)) continue
|
|
143
|
-
const
|
|
153
|
+
const cfg = resolveForDev(siteFetch)
|
|
154
|
+
const result = await executeFetch(cfg, fetchOptions)
|
|
144
155
|
if (result.data && !result.error) {
|
|
145
|
-
fetchedData.push(
|
|
156
|
+
fetchedData.push(entry(cfg, result.data))
|
|
146
157
|
}
|
|
147
158
|
}
|
|
148
159
|
|
|
@@ -151,9 +162,10 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
151
162
|
// Page-level fetch — every declaration.
|
|
152
163
|
for (const pageFetch of toFetchList(page.fetch)) {
|
|
153
164
|
if (!shouldPrefetchInDev(pageFetch)) continue
|
|
154
|
-
const
|
|
165
|
+
const cfg = resolveForDev(pageFetch)
|
|
166
|
+
const result = await executeFetch(cfg, fetchOptions)
|
|
155
167
|
if (result.data && !result.error) {
|
|
156
|
-
fetchedData.push(
|
|
168
|
+
fetchedData.push(entry(cfg, result.data))
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
171
|
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
// took `site.yml`'s values and sync took `collections.yml`'s, so an author writing
|
|
9
9
|
// `sort: date desc` here got `date asc` baked into the static file.
|
|
10
10
|
//
|
|
11
|
-
// The broken case was the one the public docs recommend.
|
|
12
|
-
// `kb/framework/plans/one-collections-config.md`.
|
|
11
|
+
// The broken case was the one the public docs recommend.
|
|
13
12
|
//
|
|
14
13
|
// ⭐ A QUERY IS SECOND-ORDER SITE CONTENT — it describes how to REACH content, and
|
|
15
14
|
// is evaluated rather than rendered. `queries.yml` is a BARE MAP of name → query at
|
|
@@ -19,7 +18,7 @@
|
|
|
19
18
|
// ⛔ THE THREE JOBS `collections/<name>/` USED TO FUSE ARE NOW THREE THINGS.
|
|
20
19
|
// `entities/{schema}/` is the pool, `records.yml` is the folder (what makes an
|
|
21
20
|
// entity a record), and a query asks the folder for a set. This file resolves the
|
|
22
|
-
// LAST of those only.
|
|
21
|
+
// LAST of those only.
|
|
23
22
|
//
|
|
24
23
|
// ⚠️ `collections.yml` and `site.yml::collections` are GONE, with no alias and no
|
|
25
24
|
// deprecation path — the model's §5 ruling, and there is nothing outside this
|
|
@@ -54,7 +54,7 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
|
|
|
54
54
|
import { existsSync } from 'node:fs'
|
|
55
55
|
import yaml from 'js-yaml'
|
|
56
56
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
57
|
-
import { DATA_DIR } from '@uniweb/core'
|
|
57
|
+
import { DATA_DIR, fillRoutePattern } from '@uniweb/core'
|
|
58
58
|
import { applyWhere, applyFilter, applySort } from './data-fetcher.js'
|
|
59
59
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
60
60
|
import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
|
|
@@ -675,13 +675,30 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
675
675
|
|
|
676
676
|
warnDuplicateSlugs(items, config.name)
|
|
677
677
|
|
|
678
|
-
//
|
|
678
|
+
// `route:` on the query — bake each record's canonical href.
|
|
679
|
+
//
|
|
680
|
+
// ⭐ THROUGH THE ONE ENCODER (`fillRoutePattern`, `@uniweb/core/route-match`),
|
|
681
|
+
// which is what the runtime's `addDetailRoute` also calls. Until 2026-09-04 this
|
|
682
|
+
// interpolated `${baseRoute}/${item.slug}` RAW while the runtime encoded, and a
|
|
683
|
+
// record already carrying a baked route keeps it — so the same record got two
|
|
684
|
+
// different hrefs depending on which lane served it (F14): a slug with a space
|
|
685
|
+
// compared unequal to `location.pathname`, and a slug with a `/` became an
|
|
686
|
+
// extra route segment. A record with no slug gets no route rather than
|
|
687
|
+
// `/blog/undefined`.
|
|
688
|
+
//
|
|
689
|
+
// `route: /blog` names the base of a `[slug]` page, so the template is
|
|
690
|
+
// `/blog/:slug`. `route: /blog/[...path]` names a `[...path]` page: the
|
|
691
|
+
// template is `/blog/:path*` and the record's placement (`path`, the folder
|
|
692
|
+
// `records.yml` put it in) becomes part of its href — `/blog/field/my-post`.
|
|
679
693
|
if (config.route) {
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
...
|
|
683
|
-
|
|
684
|
-
|
|
694
|
+
const base = config.route.replace(/\/$/, '')
|
|
695
|
+
const template = base.endsWith('/[...path]')
|
|
696
|
+
? `${base.slice(0, -'/[...path]'.length)}/:path*`
|
|
697
|
+
: `${base}/:slug`
|
|
698
|
+
items = items.map((item) => {
|
|
699
|
+
const route = fillRoutePattern(template, item)
|
|
700
|
+
return route === null ? item : { ...item, route }
|
|
701
|
+
})
|
|
685
702
|
}
|
|
686
703
|
|
|
687
704
|
// ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, filter, sort,
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
// fill it in) is guarded at the CLI with a count and a confirmation; the format
|
|
43
43
|
// stays honest and the CLI does the asking.
|
|
44
44
|
//
|
|
45
|
-
// Model:
|
|
45
|
+
// Model: entity · record · query · folder.
|
|
46
46
|
|
|
47
47
|
import { existsSync } from 'node:fs'
|
|
48
48
|
import { readFile } from 'node:fs/promises'
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// The entity has FOUR Sections — decompose only what a consumer needs to read
|
|
5
5
|
// on its own; keep coarse what is shipped whole:
|
|
6
6
|
//
|
|
7
|
-
// info single, brief — identity
|
|
8
|
-
//
|
|
9
|
-
// opening the rest
|
|
7
|
+
// info single, brief — identity: name, version, role, description, plus the
|
|
8
|
+
// producer statements a consumer must act on without
|
|
9
|
+
// opening the rest (`digest`, `runtime`, `supports`).
|
|
10
|
+
// Field-decomposed. This is the summary card.
|
|
10
11
|
// schema single — ONE opaque `schema` json field: the whole renderable
|
|
11
12
|
// schema.json MINUS identity and MINUS dataSchemas
|
|
12
13
|
// (components, layouts, outputs, plus foundation-wide config
|
|
@@ -84,6 +85,11 @@ export function foundationSchemaToEntity(schema, opts = {}) {
|
|
|
84
85
|
role: self.role || 'foundation',
|
|
85
86
|
}
|
|
86
87
|
if (self.description !== undefined) info.description = self.description
|
|
88
|
+
// The host services this foundation is built against (package.json's
|
|
89
|
+
// `uniweb.supports`). `Array.isArray`, not truthiness: `[]` is an explicit
|
|
90
|
+
// "none"; an ABSENT key means UNKNOWN. Mirrors `buildInfo` in
|
|
91
|
+
// `registry-package.js`, which is the path `uniweb register` actually takes.
|
|
92
|
+
if (Array.isArray(self.supports)) info.supports = self.supports
|
|
87
93
|
|
|
88
94
|
// ── schema — the whole renderable schema.json minus identity and minus
|
|
89
95
|
// dataSchemas, shipped WHOLE as one opaque blob. ───────────────────────────
|
|
@@ -93,6 +99,7 @@ export function foundationSchemaToEntity(schema, opts = {}) {
|
|
|
93
99
|
version: _v,
|
|
94
100
|
description: _d,
|
|
95
101
|
role: _r,
|
|
102
|
+
supports: _s,
|
|
96
103
|
...selfConfig
|
|
97
104
|
} = rest._self || {}
|
|
98
105
|
const schemaBlob = { ...rest, _self: selfConfig }
|
package/src/uwx/records.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// referenced BY NAME, on the entity-content SYNC lane.
|
|
3
3
|
//
|
|
4
4
|
// Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
|
|
5
|
-
// `$id` (the
|
|
5
|
+
// `$id` (the producer-local handle), `$model` (the Model by name), and
|
|
6
6
|
// each SINGLE section keyed by its name — the brief plus any sibling singles, not
|
|
7
7
|
// the brief alone. The backend MINTS `$uuid` on first sync and
|
|
8
8
|
// returns it in the finalized response; the verb back-fills it into the source
|
|
@@ -91,8 +91,9 @@ function stripSigils(value) {
|
|
|
91
91
|
// ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
|
|
92
92
|
// encoding rather than the reference made the folder's hash unreproducible.
|
|
93
93
|
//
|
|
94
|
-
// `refLeaf` (uwx/folder.js) emits `$ref:
|
|
95
|
-
// is brand-new and `entry: { model, entity:
|
|
94
|
+
// `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
|
|
95
|
+
// `<dirs>/<slug>` — while the record is brand-new, and `entry: { model, entity:
|
|
96
|
+
// <uuid> }` once it has been minted.
|
|
96
97
|
// Both denote the same record. A push hashes the folder BEFORE submitting, then
|
|
97
98
|
// back-fills the minted `$uuid` into every record's source file — so the very
|
|
98
99
|
// next emit builds the OTHER encoding, and the hash the push just banked can
|
|
@@ -181,7 +182,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
181
182
|
* already carries `$uuid` (back-filled from a prior sync) round-trips it.
|
|
182
183
|
*
|
|
183
184
|
* @param {object} params
|
|
184
|
-
* @param {string} params.queryName - the site.yml
|
|
185
|
+
* @param {string} params.queryName - the query's name in site.yml
|
|
185
186
|
* @param {object[]} params.records - [{ slug, ...fields }]
|
|
186
187
|
* @param {object} params.declaration - the `@uniweb/data-schema` declaration
|
|
187
188
|
* (from toDataSchemaDeclaration): `{ name, brief, sections }`
|
|
@@ -272,9 +273,22 @@ export function recordsToEntities({
|
|
|
272
273
|
warnings.push(`${queryName}: a record without a slug was skipped`)
|
|
273
274
|
continue
|
|
274
275
|
}
|
|
275
|
-
// `$id` is the payload-local handle
|
|
276
|
-
//
|
|
277
|
-
//
|
|
276
|
+
// ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
|
|
277
|
+
// the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
|
|
278
|
+
// frontmatter `$id` wins.
|
|
279
|
+
//
|
|
280
|
+
// ⚠️ The authoritative value is the record's POOL POSITION — `<dirs>/<slug>` —
|
|
281
|
+
// and it is set upstream, at the pool walk; see the ⭐ comment there, which is
|
|
282
|
+
// where the reasoning lives. `<query>/<slug>` below is only the fallback for a
|
|
283
|
+
// record that did not arrive through the pool, and it is explicitly NOT the
|
|
284
|
+
// shape identity is meant to take: two queries over one Model would mint two
|
|
285
|
+
// identities for one file.
|
|
286
|
+
//
|
|
287
|
+
// The qualification is a CONSTRAINT, not a style: the sync response is keyed per
|
|
288
|
+
// (`$model`, `$id`), so a bare slug would collide whenever two queries over the
|
|
289
|
+
// same Model reuse one (see the duplicate check below). ⇒ Do not describe this
|
|
290
|
+
// value as "the slug" — the folder leaf's `path_segment` is the bare segment, and
|
|
291
|
+
// conflating the two has already misdirected a naming decision.
|
|
278
292
|
const id = record.$id || `${queryName}/${slug}`
|
|
279
293
|
const uuid = record.$uuid || null
|
|
280
294
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
@@ -631,7 +645,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
631
645
|
// statically (the "data ball") instead, so the caller can route them there.
|
|
632
646
|
const schemaless = []
|
|
633
647
|
// The sync response is keyed per ($model, $id), so the pair must be unique
|
|
634
|
-
// within one submission (two
|
|
648
|
+
// within one submission (two queries over the same Model could otherwise
|
|
635
649
|
// reuse a slug).
|
|
636
650
|
const seen = new Set()
|
|
637
651
|
for (const { name, decl } of mapped) {
|
|
@@ -159,6 +159,11 @@ function buildInfo(self, org, digest, runtime) {
|
|
|
159
159
|
// be satisfied. Same lift as `digest`: stated by the producer, opaque to the
|
|
160
160
|
// backend, acted on by whoever resolves a whole site.
|
|
161
161
|
if (runtime) info.runtime = runtime
|
|
162
|
+
// The host services this foundation is BUILT AGAINST, from package.json's
|
|
163
|
+
// `uniweb.supports`. `Array.isArray` and not truthiness: `[]` is an explicit
|
|
164
|
+
// "none" and must survive as one, while an ABSENT key means UNKNOWN — the
|
|
165
|
+
// same three-state rule `runtime` states above, for the same reason.
|
|
166
|
+
if (Array.isArray(self.supports)) info.supports = self.supports
|
|
162
167
|
return info
|
|
163
168
|
}
|
|
164
169
|
|
|
@@ -166,7 +171,11 @@ function buildInfo(self, org, digest, runtime) {
|
|
|
166
171
|
// as one opaque object the backend never reads into (custodian).
|
|
167
172
|
function buildSchemaBlob(schema) {
|
|
168
173
|
const { dataSchemas: _ds, ...rest } = schema
|
|
169
|
-
|
|
174
|
+
// Every key hoisted into `info` is stripped here, so the wire carries each
|
|
175
|
+
// fact ONCE. Two copies of one fact is a drift liability, and the copy inside
|
|
176
|
+
// an opaque blob is the one nobody would think to update.
|
|
177
|
+
const { name: _n, version: _v, description: _d, role: _r, supports: _s, ...selfConfig } =
|
|
178
|
+
rest._self || {}
|
|
170
179
|
return { ...rest, _self: selfConfig }
|
|
171
180
|
}
|
|
172
181
|
|
package/src/uwx/site-project.js
CHANGED
|
@@ -513,7 +513,12 @@ function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
|
|
|
513
513
|
// is already a plain string.
|
|
514
514
|
export function pageDirName(record, sourceLocale) {
|
|
515
515
|
const slug = unwrapLocalized(record.slug, sourceLocale)
|
|
516
|
-
|
|
516
|
+
if (!record.is_dynamic) return slug
|
|
517
|
+
// The multi-segment folder rides the wire as slug `...path` with
|
|
518
|
+
// `param_name: slug` (the handle it delivers by); it comes back as the one
|
|
519
|
+
// fixed spelling, never as `[slug]`.
|
|
520
|
+
if (slug === '...path') return '[...path]'
|
|
521
|
+
return `[${record.param_name || slug}]`
|
|
517
522
|
}
|
|
518
523
|
|
|
519
524
|
// Pass 1 — write + relocate every page dir, its page.yml/folder.yml, and its
|
package/src/uwx/site.js
CHANGED
|
@@ -334,6 +334,12 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
const DYNAMIC_RE = /^\[(.+)\]$/
|
|
337
|
+
// The multi-segment route folder, one fixed spelling (`content-collector.js`).
|
|
338
|
+
// On the wire its page `slug` is the marker itself (`...path`) and its
|
|
339
|
+
// `param_name` is `slug`: the record is delivered by its handle, the last
|
|
340
|
+
// segment, exactly as under `[slug]`. ⚠️ What a consumer's projector emits as
|
|
341
|
+
// the page ROUTE for it is that consumer's; framework expects `/…/:path*`.
|
|
342
|
+
const CATCH_ALL_MARKER = '...path'
|
|
337
343
|
|
|
338
344
|
// ===========================================================================
|
|
339
345
|
// NESTED ($-document) lane — Phase 0 de-flatten (bidirectional-sync §8).
|
|
@@ -545,7 +551,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
|
|
|
545
551
|
slug,
|
|
546
552
|
mode,
|
|
547
553
|
isDynamic: !!dyn,
|
|
548
|
-
paramName: dyn ? dyn[1] : undefined,
|
|
554
|
+
paramName: dyn ? (dyn[1] === CATCH_ALL_MARKER ? 'slug' : dyn[1]) : undefined,
|
|
549
555
|
isRoot,
|
|
550
556
|
siteIndex,
|
|
551
557
|
sourceLocale,
|
|
@@ -684,7 +690,7 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
684
690
|
* declaration could occupy, nothing is ever recorded for one, and every push re-sent
|
|
685
691
|
* this whole section uuid-less. The backend refuses that (an all-blank section over
|
|
686
692
|
* stored items would delete every stored row), which is why `push` worked once and
|
|
687
|
-
* every push after it was refused. Measured 2026-08-29; collab framework
|
|
693
|
+
* every push after it was refused. Measured 2026-08-29; collab framework↔backend.
|
|
688
694
|
*
|
|
689
695
|
* ⭐ `name` is the right key and not merely the available one — the backend enforces
|
|
690
696
|
* `unique_field(name, scope: section)` on this section, and it is the join key its
|
|
@@ -724,8 +730,6 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
724
730
|
* `site.yml collections.<name>.label`; no such field has ever existed, and they have
|
|
725
731
|
* corrected it.
|
|
726
732
|
*
|
|
727
|
-
* ⇒ Full record, including what is established vs merely claimed:
|
|
728
|
-
* `kb/framework/build/collections-decl-open-questions.md`.
|
|
729
733
|
*
|
|
730
734
|
* @param {object} declarations resolved collection declarations, keyed by name
|
|
731
735
|
* @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
|
package/src/dev-backend.js
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Dev backend for testing Uniweb sites with `supports: [where, limit, sort]`.
|
|
4
|
-
*
|
|
5
|
-
* Reads a directory of YAML recordsByQuery (each subfolder is a collection,
|
|
6
|
-
* each .yml file inside is a record) and exposes them via HTTP. Evaluates
|
|
7
|
-
* where-objects on the server side using @uniweb/core's matchWhere — the
|
|
8
|
-
* exact same evaluator the runtime uses as a fallback. This lets you
|
|
9
|
-
* develop a site against a "real" backend without standing up a database.
|
|
10
|
-
*
|
|
11
|
-
* Wire format matches the framework default fetcher's pushdown conventions
|
|
12
|
-
* (see framework/runtime/src/default-fetcher.js):
|
|
13
|
-
*
|
|
14
|
-
* GET /api/{collection} — all records
|
|
15
|
-
* GET /api/{collection}?_where=<JSON> — filtered by where-object
|
|
16
|
-
* GET /api/{collection}?_limit=N — first N records
|
|
17
|
-
* GET /api/{collection}?_sort=field:dir — sorted
|
|
18
|
-
* POST /api/{collection} body: { where, ... } — operators in body
|
|
19
|
-
* GET /api/{collection}/{slug} — single record
|
|
20
|
-
*
|
|
21
|
-
* Usage:
|
|
22
|
-
* node scripts/framework/dev-backend.js --recordsByQuery <path> [--port N]
|
|
23
|
-
*
|
|
24
|
-
* Example (academic-metrics):
|
|
25
|
-
* node scripts/framework/dev-backend.js \
|
|
26
|
-
* --recordsByQuery framework/templates/academic-metrics/site/recordsByQuery \
|
|
27
|
-
* --port 8080
|
|
28
|
-
*
|
|
29
|
-
* Then in the site's site.yml:
|
|
30
|
-
* fetcher:
|
|
31
|
-
* baseUrl: http://localhost:8080
|
|
32
|
-
* supports: [where, limit, sort]
|
|
33
|
-
*
|
|
34
|
-
* And rewrite collection refs to URLs, e.g.:
|
|
35
|
-
* fetch: { url: /api/members, schema: members }
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import { createServer } from 'node:http'
|
|
39
|
-
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
40
|
-
import { existsSync } from 'node:fs'
|
|
41
|
-
import { resolve, join, basename, extname } from 'node:path'
|
|
42
|
-
import { parseArgs } from 'node:util'
|
|
43
|
-
import yaml from 'js-yaml'
|
|
44
|
-
import { matchWhere } from '@uniweb/core'
|
|
45
|
-
|
|
46
|
-
const { values } = parseArgs({
|
|
47
|
-
options: {
|
|
48
|
-
entities: { type: 'string', short: 'e' },
|
|
49
|
-
port: { type: 'string', short: 'p', default: '8080' },
|
|
50
|
-
},
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
if (!values.entities) {
|
|
54
|
-
console.error('Usage: dev-backend.js --entities <path> [--port N]')
|
|
55
|
-
process.exit(1)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const ENTITIES_ROOT = resolve(values.entities)
|
|
59
|
-
const PORT = Number(values.port)
|
|
60
|
-
|
|
61
|
-
if (!existsSync(ENTITIES_ROOT)) {
|
|
62
|
-
console.error(`Entities directory not found: ${ENTITIES_ROOT}`)
|
|
63
|
-
process.exit(1)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─── Load recordsByQuery from disk ─────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
async function loadRecords(dir) {
|
|
69
|
-
const files = await readdir(dir)
|
|
70
|
-
const items = []
|
|
71
|
-
for (const file of files) {
|
|
72
|
-
const ext = extname(file).toLowerCase()
|
|
73
|
-
if (!['.yml', '.yaml', '.json'].includes(ext)) continue
|
|
74
|
-
const filepath = join(dir, file)
|
|
75
|
-
const content = await readFile(filepath, 'utf8')
|
|
76
|
-
let data
|
|
77
|
-
try {
|
|
78
|
-
data = ext === '.json' ? JSON.parse(content) : yaml.load(content)
|
|
79
|
-
} catch (err) {
|
|
80
|
-
console.warn(`[dev-backend] Failed to parse ${filepath}: ${err.message}`)
|
|
81
|
-
continue
|
|
82
|
-
}
|
|
83
|
-
if (data == null) continue
|
|
84
|
-
const slug = basename(file, ext)
|
|
85
|
-
if (Array.isArray(data)) {
|
|
86
|
-
// Array-form file: each element is a record.
|
|
87
|
-
for (const record of data) {
|
|
88
|
-
if (record && typeof record === 'object') items.push(record)
|
|
89
|
-
}
|
|
90
|
-
} else if (typeof data === 'object') {
|
|
91
|
-
items.push({ slug, ...data })
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return items
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function loadAllRecords() {
|
|
98
|
-
const entries = await readdir(ENTITIES_ROOT)
|
|
99
|
-
const recordsByQuery = {}
|
|
100
|
-
for (const name of entries) {
|
|
101
|
-
const fullPath = join(ENTITIES_ROOT, name)
|
|
102
|
-
const s = await stat(fullPath)
|
|
103
|
-
if (!s.isDirectory()) continue
|
|
104
|
-
recordsByQuery[name] = await loadRecords(fullPath)
|
|
105
|
-
console.log(`[dev-backend] Loaded ${recordsByQuery[name].length} items from "${name}"`)
|
|
106
|
-
}
|
|
107
|
-
return recordsByQuery
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ─── Operator handling (mirrors default-fetcher pushdown wire format) ───────
|
|
111
|
-
|
|
112
|
-
function applyOperators(items, operators) {
|
|
113
|
-
let result = items
|
|
114
|
-
if (operators.where) {
|
|
115
|
-
result = matchWhere(operators.where, result)
|
|
116
|
-
}
|
|
117
|
-
if (operators.sort) {
|
|
118
|
-
result = applySort(result, operators.sort)
|
|
119
|
-
}
|
|
120
|
-
if (typeof operators.limit === 'number' && operators.limit > 0) {
|
|
121
|
-
result = result.slice(0, operators.limit)
|
|
122
|
-
}
|
|
123
|
-
return result
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function applySort(items, sortExpr) {
|
|
127
|
-
const sorts = String(sortExpr).split(',').map((s) => {
|
|
128
|
-
const [field, dir = 'asc'] = s.trim().split(/\s+/)
|
|
129
|
-
return { field, desc: dir.toLowerCase() === 'desc' }
|
|
130
|
-
})
|
|
131
|
-
return [...items].sort((a, b) => {
|
|
132
|
-
for (const { field, desc } of sorts) {
|
|
133
|
-
const av = a?.[field] ?? ''
|
|
134
|
-
const bv = b?.[field] ?? ''
|
|
135
|
-
if (av < bv) return desc ? 1 : -1
|
|
136
|
-
if (av > bv) return desc ? -1 : 1
|
|
137
|
-
}
|
|
138
|
-
return 0
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function parseOperatorsFromQuery(searchParams) {
|
|
143
|
-
const out = {}
|
|
144
|
-
if (searchParams.has('_where')) {
|
|
145
|
-
try {
|
|
146
|
-
out.where = JSON.parse(searchParams.get('_where'))
|
|
147
|
-
} catch (err) {
|
|
148
|
-
throw new Error(`Invalid _where JSON: ${err.message}`)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (searchParams.has('_limit')) {
|
|
152
|
-
out.limit = Number(searchParams.get('_limit'))
|
|
153
|
-
}
|
|
154
|
-
if (searchParams.has('_sort')) {
|
|
155
|
-
out.sort = searchParams.get('_sort')
|
|
156
|
-
}
|
|
157
|
-
return out
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function readJsonBody(req) {
|
|
161
|
-
return new Promise((resolve, reject) => {
|
|
162
|
-
let body = ''
|
|
163
|
-
req.on('data', (chunk) => { body += chunk })
|
|
164
|
-
req.on('end', () => {
|
|
165
|
-
if (!body) return resolve({})
|
|
166
|
-
try { resolve(JSON.parse(body)) }
|
|
167
|
-
catch (err) { reject(new Error(`Invalid JSON body: ${err.message}`)) }
|
|
168
|
-
})
|
|
169
|
-
req.on('error', reject)
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ─── HTTP server ────────────────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
function send(res, status, body) {
|
|
176
|
-
res.writeHead(status, {
|
|
177
|
-
'Content-Type': 'application/json',
|
|
178
|
-
'Access-Control-Allow-Origin': '*',
|
|
179
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
180
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
181
|
-
})
|
|
182
|
-
res.end(typeof body === 'string' ? body : JSON.stringify(body))
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function handleRequest(req, res, recordsByQuery) {
|
|
186
|
-
if (req.method === 'OPTIONS') return send(res, 204, '')
|
|
187
|
-
|
|
188
|
-
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
189
|
-
const match = url.pathname.match(/^\/api\/([^/]+)(?:\/([^/]+))?$/)
|
|
190
|
-
if (!match) return send(res, 404, { error: 'Not found' })
|
|
191
|
-
|
|
192
|
-
const [, queryName, slug] = match
|
|
193
|
-
const items = recordsByQuery[queryName]
|
|
194
|
-
if (!items) return send(res, 404, { error: `Unknown query: ${queryName}` })
|
|
195
|
-
|
|
196
|
-
// Single record by slug.
|
|
197
|
-
if (slug) {
|
|
198
|
-
const item = items.find((r) => r?.slug === slug)
|
|
199
|
-
if (!item) return send(res, 404, { error: `No record with slug "${slug}"` })
|
|
200
|
-
return send(res, 200, item)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Collection — apply operators from query string (GET) or body (POST).
|
|
204
|
-
let operators
|
|
205
|
-
try {
|
|
206
|
-
operators = req.method === 'POST'
|
|
207
|
-
? await readJsonBody(req)
|
|
208
|
-
: parseOperatorsFromQuery(url.searchParams)
|
|
209
|
-
} catch (err) {
|
|
210
|
-
return send(res, 400, { error: err.message })
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
let result
|
|
214
|
-
try {
|
|
215
|
-
result = applyOperators(items, operators)
|
|
216
|
-
} catch (err) {
|
|
217
|
-
return send(res, 400, { error: `Operator evaluation failed: ${err.message}` })
|
|
218
|
-
}
|
|
219
|
-
return send(res, 200, result)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ─── Boot ───────────────────────────────────────────────────────────────────
|
|
223
|
-
|
|
224
|
-
const recordsByQuery = await loadAllRecords()
|
|
225
|
-
const knownQueries = Object.keys(recordsByQuery)
|
|
226
|
-
if (knownQueries.length === 0) {
|
|
227
|
-
console.warn('[dev-backend] No recordsByQuery found.')
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const server = createServer((req, res) => {
|
|
231
|
-
handleRequest(req, res, recordsByQuery).catch((err) => {
|
|
232
|
-
console.error('[dev-backend] Request handler threw:', err)
|
|
233
|
-
send(res, 500, { error: 'Internal server error' })
|
|
234
|
-
})
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
server.listen(PORT, () => {
|
|
238
|
-
console.log(`[dev-backend] Listening on http://localhost:${PORT}`)
|
|
239
|
-
console.log(`[dev-backend] Queries: ${knownQueries.join(', ') || '(none)'}`)
|
|
240
|
-
console.log('[dev-backend] Endpoints:')
|
|
241
|
-
for (const name of knownQueries) {
|
|
242
|
-
console.log(` GET /api/${name} — all records`)
|
|
243
|
-
console.log(` GET /api/${name}?_where=<JSON> — filtered`)
|
|
244
|
-
console.log(` GET /api/${name}/{slug} — single record`)
|
|
245
|
-
console.log(` POST /api/${name} body: { where } — operators in body`)
|
|
246
|
-
}
|
|
247
|
-
})
|