@uniweb/build 0.44.4 → 0.45.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 +221 -68
- package/src/site/content-collector.js +68 -27
- package/src/site/data-fetcher.js +49 -5
- package/src/site/query-processor.js +33 -4
- package/src/uwx/asset-map.js +74 -2
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +2 -1
- package/src/uwx/records-project.js +21 -26
- package/src/uwx/records.js +6 -7
- package/src/uwx/self-scope.js +78 -0
- package/src/uwx/site-project.js +13 -5
- package/src/uwx/site.js +65 -9
- package/src/uwx/sync-package.js +24 -9
- package/src/uwx/yaml-upsert.js +26 -0
|
@@ -30,7 +30,7 @@ import yaml from 'js-yaml'
|
|
|
30
30
|
import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
|
|
31
31
|
import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
|
|
32
32
|
import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
|
|
33
|
-
import { parseFetchConfig
|
|
33
|
+
import { parseFetchConfig } from './data-fetcher.js'
|
|
34
34
|
import { resolveExtensionUrls } from './extension-urls.js'
|
|
35
35
|
import { buildTheme, extractFoundationVars } from '../theme/index.js'
|
|
36
36
|
import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
|
|
@@ -122,6 +122,51 @@ function extractRouteParam(folderName) {
|
|
|
122
122
|
return match ? match[1] : null
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Folders the build refuses on a route, ruled 2026-09-11 [Diego]:
|
|
127
|
+
*
|
|
128
|
+
* - `[dir]` and `[path]` — `:dir` and `:path` are route variables every
|
|
129
|
+
* parametric page already has, so a folder by either name would make one
|
|
130
|
+
* name mean two values (and `[path]` is most often a mistyped `[...path]`);
|
|
131
|
+
* - any folder inside a `[...path]` folder — the catch-all takes the rest of
|
|
132
|
+
* the URL, so a page below it has a route (`/docs/:path*\/edit`) that can
|
|
133
|
+
* never match. A folder that holds something other than a page is named
|
|
134
|
+
* with a leading `_`, which the walk skips.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name - the folder's name
|
|
137
|
+
* @param {string} parentRoute - the route of the folder it sits in
|
|
138
|
+
*/
|
|
139
|
+
function assertRouteFolder(name, parentRoute) {
|
|
140
|
+
if (name === '[dir]' || name === '[path]') {
|
|
141
|
+
const hint = name === '[path]' ? ' Did you mean `[...path]`, which captures a path of any depth?' : ''
|
|
142
|
+
throw new Error(
|
|
143
|
+
`[uniweb] pages: a folder cannot be named \`${name}\` — \`:${name.slice(1, -1)}\` is a route ` +
|
|
144
|
+
`variable every parametric page already has.${hint}`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (typeof parentRoute === 'string' && /\/:[A-Za-z0-9_-]+\*(\/|$)/.test(parentRoute)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[uniweb] pages: \`${name}\` sits inside a \`[...path]\` folder (${parentRoute}). The catch-all ` +
|
|
150
|
+
`takes the rest of the URL, so a page below it could never be reached. Move it beside the ` +
|
|
151
|
+
`\`[...path]\` folder, or name it \`_${name}\` if it holds something other than a page.`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The route param a page nested inside a parametric page binds — its nearest
|
|
158
|
+
* parametric ancestor's, the DEEPEST `:param` of the route it sits under. Null
|
|
159
|
+
* when no ancestor is parametric.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} parentRoute
|
|
162
|
+
* @returns {string|null}
|
|
163
|
+
*/
|
|
164
|
+
function inheritedRouteParam(parentRoute) {
|
|
165
|
+
if (typeof parentRoute !== 'string') return null
|
|
166
|
+
const params = [...parentRoute.matchAll(/:([A-Za-z0-9_-]+)(?=\/|$)/g)].map((m) => m[1])
|
|
167
|
+
return params.length ? params[params.length - 1] : null
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
// ─────────────────────────────────────────────────────────────────
|
|
126
171
|
// Version Detection
|
|
127
172
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -866,7 +911,6 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
|
|
|
866
911
|
lastModified: fileStat.mtime?.toISOString() || null,
|
|
867
912
|
isDynamic: false,
|
|
868
913
|
paramName: null,
|
|
869
|
-
parentSchema: null,
|
|
870
914
|
version: null,
|
|
871
915
|
versionMeta: null,
|
|
872
916
|
versionScope: null,
|
|
@@ -1467,12 +1511,19 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1467
1511
|
// Determine route
|
|
1468
1512
|
// Index pages get the parent route as their canonical route (no dual routes)
|
|
1469
1513
|
// sourcePath stores the original folder-based path for ancestor checking
|
|
1470
|
-
|
|
1471
|
-
|
|
1514
|
+
// ⭐ A page is PARAMETRIC when its folder is a bracket name or it sits inside
|
|
1515
|
+
// one (ruled 2026-09-11 [Diego]): `pages/members/[slug]/cv/` is `/members/:slug/cv`
|
|
1516
|
+
// and binds its ancestor's `slug`. Every lane — the SPA, the prefetch, the static
|
|
1517
|
+
// build — tells a parametric page by the parameter in its route; this flag says
|
|
1518
|
+
// the same thing to a consumer that reads the flag.
|
|
1519
|
+
const isBracket = isDynamicRoute(pageName)
|
|
1520
|
+
const inheritedParam = isBracket ? null : inheritedRouteParam(parentRoute)
|
|
1521
|
+
const isDynamic = isBracket || inheritedParam !== null
|
|
1522
|
+
const paramName = isBracket ? extractRouteParam(pageName) : inheritedParam
|
|
1472
1523
|
|
|
1473
1524
|
// First, calculate the folder-based route (what the route would be without index handling)
|
|
1474
1525
|
let folderRoute
|
|
1475
|
-
if (
|
|
1526
|
+
if (isBracket) {
|
|
1476
1527
|
// Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
|
|
1477
1528
|
// /blog/[...path] → /blog/:path* — the one multi-segment token the matcher knows.
|
|
1478
1529
|
const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
|
|
@@ -1500,23 +1551,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1500
1551
|
const layoutObj = mergeLayoutConfig(inheritedLayout, normalizeLayoutConfig(layoutConfig))
|
|
1501
1552
|
const resolvedLayoutName = layoutObj.name || null
|
|
1502
1553
|
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
// ⛔ This is a genuine cardinality constraint, not a limit worth lifting: a
|
|
1512
|
-
// route pattern names one variable, and "which collection does `:slug` index"
|
|
1513
|
-
// has no second answer. A page that needs another dataset alongside its
|
|
1514
|
-
// dynamic one still declares it — plurality is what makes that sayable.
|
|
1515
|
-
let parentSchema = null
|
|
1516
|
-
if (isDynamic && parentFetch) {
|
|
1517
|
-
const [first] = toFetchList(parentFetch)
|
|
1518
|
-
parentSchema = first ? first.as : null
|
|
1519
|
-
}
|
|
1554
|
+
// ⛔ NO `parentSchema`. Which query a parametric page's URL names one record of
|
|
1555
|
+
// — its ROUTE QUERY — is worked out where it is read, by one function every lane
|
|
1556
|
+
// calls (`routeQuery`, `@uniweb/core/fetch-config`): the page's own query, its
|
|
1557
|
+
// parent's, the site's, or its sections' shared key. This emitted a copy chosen
|
|
1558
|
+
// by another rule (the closest ancestor with a query at ANY depth, never the
|
|
1559
|
+
// page's own or the site's), so the URL narrowed nothing, or a key no section
|
|
1560
|
+
// received — measured 2026-09-10. Removed 2026-09-11 [Diego].
|
|
1520
1561
|
|
|
1521
1562
|
return {
|
|
1522
1563
|
page: {
|
|
@@ -1535,10 +1576,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1535
1576
|
: {}),
|
|
1536
1577
|
lastModified: lastModified?.toISOString(),
|
|
1537
1578
|
|
|
1538
|
-
//
|
|
1579
|
+
// Parametric route metadata
|
|
1539
1580
|
isDynamic,
|
|
1540
|
-
paramName, // e.g., "slug" from [slug]
|
|
1541
|
-
parentSchema, // e.g., "articles" - the data array to iterate over
|
|
1581
|
+
paramName, // e.g., "slug" from [slug]; a nested page's ancestor's
|
|
1542
1582
|
|
|
1543
1583
|
// Version metadata (if within a versioned section)
|
|
1544
1584
|
version: versionContext?.version || null,
|
|
@@ -1872,6 +1912,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1872
1912
|
// Process subdirectories
|
|
1873
1913
|
for (const folder of orderedFolders) {
|
|
1874
1914
|
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
1915
|
+
assertRouteFolder(entry, parentRoute)
|
|
1875
1916
|
const isIndex = entry === indexName
|
|
1876
1917
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
1877
1918
|
|
|
@@ -1930,7 +1971,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1930
1971
|
lastModified: null,
|
|
1931
1972
|
isDynamic: false,
|
|
1932
1973
|
paramName: null,
|
|
1933
|
-
parentSchema: null,
|
|
1934
1974
|
version: versionContext?.version || null,
|
|
1935
1975
|
versionMeta: versionContext?.versionMeta || null,
|
|
1936
1976
|
versionScope: versionContext?.scope || null,
|
|
@@ -2001,6 +2041,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2001
2041
|
// Second pass: process each page folder
|
|
2002
2042
|
for (const folder of orderedFolders) {
|
|
2003
2043
|
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
2044
|
+
assertRouteFolder(entry, parentRoute)
|
|
2004
2045
|
const isIndex = entry === indexPageName
|
|
2005
2046
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
2006
2047
|
|
|
@@ -2024,7 +2065,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2024
2065
|
lastModified: null,
|
|
2025
2066
|
isDynamic: false,
|
|
2026
2067
|
paramName: null,
|
|
2027
|
-
parentSchema: null,
|
|
2028
2068
|
version: versionContext?.version || null,
|
|
2029
2069
|
versionMeta: versionContext?.versionMeta || null,
|
|
2030
2070
|
versionScope: versionContext?.scope || null,
|
|
@@ -2835,6 +2875,7 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
|
|
|
2835
2875
|
}
|
|
2836
2876
|
|
|
2837
2877
|
export {
|
|
2878
|
+
assertRouteFolder,
|
|
2838
2879
|
buildRouteTranslations,
|
|
2839
2880
|
extractItemName,
|
|
2840
2881
|
parseWildcardArray,
|
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, sortRecords, queryDataUrl } from '@uniweb/core'
|
|
23
|
+
import { matchWhere, sortRecords, queryDataUrl, applyScope } from '@uniweb/core'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Infer schema name from path or URL
|
|
@@ -115,10 +115,16 @@ export function applyWhere(items, where) {
|
|
|
115
115
|
*/
|
|
116
116
|
export function applyPostProcessing(data, config) {
|
|
117
117
|
if (!data || !Array.isArray(data)) return data
|
|
118
|
-
if (!config.where && !config.sort && !config.limit) return data
|
|
118
|
+
if (!config.scope && !config.where && !config.sort && !config.limit) return data
|
|
119
119
|
|
|
120
120
|
let result = data
|
|
121
121
|
|
|
122
|
+
// `scope` first — the folder branch the rest of the query reads, over each
|
|
123
|
+
// record's placement (`path`), as the runtime's default fetcher applies it.
|
|
124
|
+
if (typeof config.scope === 'string' && config.scope) {
|
|
125
|
+
result = applyScope(result, config.scope)
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
// Apply where-object predicate first (new path)
|
|
123
129
|
if (config.where) {
|
|
124
130
|
result = applyWhere(result, config.where)
|
|
@@ -176,16 +182,50 @@ const RECOGNIZED_FETCH_KEYS = {
|
|
|
176
182
|
// dropped in the one way the author could not see: no warning, and a plausible
|
|
177
183
|
// key inferred from the path in its place. It has its own message below, since
|
|
178
184
|
// "unrecognized" understates a key that used to work.
|
|
185
|
+
// ⭐ `scope` is recognized since 2026-09-11, when a folder branch became `scope:`
|
|
186
|
+
// on both lanes and `where: { path: { under } }` was retired in its favour. It
|
|
187
|
+
// was dropped here as "unrecognized" until then, so a page could not narrow a
|
|
188
|
+
// query to a branch at all.
|
|
179
189
|
query: new Set([
|
|
180
190
|
'query', 'as', 'prerender', 'merge', 'transform',
|
|
181
|
-
'where', 'limit', 'sort', 'detailPage',
|
|
191
|
+
'scope', 'where', 'limit', 'sort', 'detailPage',
|
|
182
192
|
]),
|
|
183
193
|
source: new Set([
|
|
184
194
|
'path', 'url', 'as', 'prerender', 'merge', 'transform', 'detail',
|
|
185
|
-
'detailPage', 'where', 'limit', 'sort',
|
|
195
|
+
'detailPage', 'scope', 'where', 'limit', 'sort',
|
|
186
196
|
]),
|
|
187
197
|
}
|
|
188
198
|
|
|
199
|
+
/**
|
|
200
|
+
* ⛔ `under` IS RETIRED (2026-09-11 [Diego]) — refused, like every retired spelling
|
|
201
|
+
* here, because an ignored predicate is a silently wrong answer. It existed for
|
|
202
|
+
* `where: { path: { under: X } }`, a folder branch written before a query had
|
|
203
|
+
* `scope:`; a branch is `scope: X` now, on both lanes, and the evaluator no longer
|
|
204
|
+
* knows the operator, so a `where` still carrying it would match nothing.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object|undefined} where
|
|
207
|
+
* @param {string} context - where the declaration sits, for the message
|
|
208
|
+
*/
|
|
209
|
+
export function refuseUnder(where, context) {
|
|
210
|
+
const walk = (node) => {
|
|
211
|
+
if (Array.isArray(node)) {
|
|
212
|
+
node.forEach(walk)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (!node || typeof node !== 'object') return
|
|
216
|
+
for (const [key, value] of Object.entries(node)) {
|
|
217
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, 'under')) {
|
|
218
|
+
const instead = key === 'path' && typeof value.under === 'string'
|
|
219
|
+
? `Write \`scope: ${JSON.stringify(value.under)}\` — the same folder branch, on every lane.`
|
|
220
|
+
: 'A folder branch is `scope:`; `under` is no longer an operator.'
|
|
221
|
+
throw new Error(`[uniweb] ${context}: \`where: { ${key}: { under: … } }\` is retired. ${instead}`)
|
|
222
|
+
}
|
|
223
|
+
walk(value)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
walk(where)
|
|
227
|
+
}
|
|
228
|
+
|
|
189
229
|
// Keys that are neither recognized nor merely unknown: they USED to work, and a
|
|
190
230
|
// generic "unrecognized key" line understates that. Each has a dedicated message
|
|
191
231
|
// naming its replacement, so this table only has to keep the generic report from
|
|
@@ -300,6 +340,7 @@ export function parseFetchConfig(fetch) {
|
|
|
300
340
|
'per-instance refinement of the ancestor fetch, under its current name.'
|
|
301
341
|
)
|
|
302
342
|
}
|
|
343
|
+
refuseUnder(fetch.where, 'fetch')
|
|
303
344
|
|
|
304
345
|
// Refine config: { refine: true, detail: false, limit: 3 }
|
|
305
346
|
// No URL — merges with the parent fetch config at runtime; only carries
|
|
@@ -368,7 +409,8 @@ export function parseFetchConfig(fetch) {
|
|
|
368
409
|
prerender: fetch.prerender ?? true,
|
|
369
410
|
merge: fetch.merge ?? false,
|
|
370
411
|
transform: fetch.transform,
|
|
371
|
-
// Query operators
|
|
412
|
+
// Query operators — a fetch's own override the named query's, per field
|
|
413
|
+
scope: fetch.scope,
|
|
372
414
|
where: fetch.where,
|
|
373
415
|
limit: fetch.limit,
|
|
374
416
|
sort: fetch.sort,
|
|
@@ -389,6 +431,7 @@ export function parseFetchConfig(fetch) {
|
|
|
389
431
|
detail,
|
|
390
432
|
detailPage,
|
|
391
433
|
// Query operators
|
|
434
|
+
scope,
|
|
392
435
|
where,
|
|
393
436
|
limit,
|
|
394
437
|
sort,
|
|
@@ -413,6 +456,7 @@ export function parseFetchConfig(fetch) {
|
|
|
413
456
|
// Canonical detail page for a list card's href (page:<stable_id>).
|
|
414
457
|
detailPage,
|
|
415
458
|
// Query operators
|
|
459
|
+
scope,
|
|
416
460
|
where,
|
|
417
461
|
limit,
|
|
418
462
|
sort,
|
|
@@ -54,8 +54,8 @@ 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, fillRoutePattern } from '@uniweb/core'
|
|
58
|
-
import { applyWhere, applySort } from './data-fetcher.js'
|
|
57
|
+
import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
|
|
58
|
+
import { applyWhere, applySort, refuseUnder } from './data-fetcher.js'
|
|
59
59
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
60
60
|
import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
|
|
61
61
|
import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
|
|
@@ -113,6 +113,7 @@ function parseQueryConfig(name, config) {
|
|
|
113
113
|
schema: config,
|
|
114
114
|
url: null,
|
|
115
115
|
route: null,
|
|
116
|
+
scope: null,
|
|
116
117
|
sort: null,
|
|
117
118
|
where: null,
|
|
118
119
|
filter: null,
|
|
@@ -122,6 +123,7 @@ function parseQueryConfig(name, config) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
refuseUnder(config.where, `queries.${name}`)
|
|
125
127
|
return {
|
|
126
128
|
name,
|
|
127
129
|
// The query's schema selects its records from the pool — `entities/{schema}/`
|
|
@@ -129,6 +131,9 @@ function parseQueryConfig(name, config) {
|
|
|
129
131
|
schema: config.schema || null,
|
|
130
132
|
url: config.url || null,
|
|
131
133
|
route: config.route || null,
|
|
134
|
+
// The folder branch the query reads (`records.yml` placement). ⛔ Not read
|
|
135
|
+
// here until 2026-09-11: a named query's `scope` was ignored on this lane.
|
|
136
|
+
scope: typeof config.scope === 'string' ? config.scope : null,
|
|
132
137
|
sort: config.sort || null,
|
|
133
138
|
// `where:` is the CANONICAL predicate; `filter:` is the deprecated string DSL
|
|
134
139
|
// it replaced. Both are carried and both are applied below, in the same order
|
|
@@ -673,6 +678,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
673
678
|
// Filter out nulls (unpublished items)
|
|
674
679
|
items = items.filter(Boolean)
|
|
675
680
|
|
|
681
|
+
// ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
|
|
682
|
+
// field a `[slug]` or `[...path]` page matches, and the one the records service
|
|
683
|
+
// serves. It is the record's FINAL slug: set here, after every format has been
|
|
684
|
+
// read and flattened, so a frontmatter `slug:` (which wins over the filename),
|
|
685
|
+
// a BibTeX cite key and an array-form file's own `slug` all count — exactly what
|
|
686
|
+
// our sync sends as the entry's name (`uwx/entity-source.js`). `slug` stays:
|
|
687
|
+
// foundations and templates read it.
|
|
688
|
+
items = items.map((item) => (
|
|
689
|
+
item && typeof item === 'object' && item.slug !== undefined && item.slug !== null && item.slug !== ''
|
|
690
|
+
? { ...item, $name: String(item.slug) }
|
|
691
|
+
: item
|
|
692
|
+
))
|
|
693
|
+
|
|
676
694
|
warnDuplicateSlugs(items, config.name)
|
|
677
695
|
|
|
678
696
|
// `route:` on the query — bake each record's canonical href.
|
|
@@ -711,8 +729,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
711
729
|
// the sync wire, stored — and never applied, while the DEPRECATED one it replaced
|
|
712
730
|
// worked. An author following current guidance got silence and shipped unfiltered
|
|
713
731
|
// data. Pinned by `tests/collection-query-terms.test.js`.
|
|
714
|
-
|
|
715
|
-
|
|
732
|
+
// ⭐ ONLY THE `where` FIXED FOR EVERY PAGE. A clause bound to the route —
|
|
733
|
+
// `where: { tag: :dir }` — cannot be applied to a file written once for every
|
|
734
|
+
// page; the runtime binds it per page (`@uniweb/core/fetch-config`,
|
|
735
|
+
// `resolveQuerySource`). ⛔ Until 2026-09-11 it was applied here to the literal
|
|
736
|
+
// `':dir'`, and the query compiled to no records (measured).
|
|
737
|
+
//
|
|
738
|
+
// ⛔ `scope` is NEVER baked, fixed or routed. The runtime applies the one that
|
|
739
|
+
// wins — a page fetch's own, else this query's — which is what the records
|
|
740
|
+
// service does. Baked here, a page's `scope:` could only narrow inside the
|
|
741
|
+
// query's branch on a static site and would replace it on a hosted one.
|
|
742
|
+
const fixed = withoutRouteVariables({ where: config.where })
|
|
743
|
+
if (fixed.where) {
|
|
744
|
+
items = applyWhere(items, fixed.where)
|
|
716
745
|
}
|
|
717
746
|
|
|
718
747
|
// Apply sort
|
package/src/uwx/asset-map.js
CHANGED
|
@@ -36,11 +36,21 @@
|
|
|
36
36
|
* `config.assets.url` pattern is the whole address, and only the host owns the
|
|
37
37
|
* second half.
|
|
38
38
|
*
|
|
39
|
+
* ⚖️ **What it holds instead is a FINGERPRINT of the served URL** (`served`), and the
|
|
40
|
+
* difference is the point: a hash can recognize an address and cannot compose one.
|
|
41
|
+
* It exists for references that are a BARE STRING — `info.preview`, `info.favicon`,
|
|
42
|
+
* `seo.image`, a section param — where there is no object to stamp `assetId` beside,
|
|
43
|
+
* so the stored value is the serve URL alone. `pull` hashes such a string and, when
|
|
44
|
+
* it matches, puts back the path the author wrote. Should the host ever serve an
|
|
45
|
+
* asset at a new address, the fingerprint simply stops matching and the pull leaves
|
|
46
|
+
* the URL — the honest projection — until the next push records the new one.
|
|
47
|
+
*
|
|
39
48
|
* ⛔ **No mime or size.** The store validates those and they are its to change;
|
|
40
49
|
* a second copy here is a second thing to disagree.
|
|
41
50
|
*/
|
|
42
51
|
|
|
43
52
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
53
|
+
import { createHash } from 'node:crypto'
|
|
44
54
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
45
55
|
import { join } from 'node:path'
|
|
46
56
|
|
|
@@ -70,6 +80,7 @@ export function readAssetMap(siteDir) {
|
|
|
70
80
|
for (const [ref, v] of Object.entries(assets)) {
|
|
71
81
|
if (v && typeof v.id === 'string' && v.id) {
|
|
72
82
|
out[ref] = { id: v.id, ext: typeof v.ext === 'string' ? v.ext : '' }
|
|
83
|
+
if (typeof v.served === 'string' && v.served) out[ref].served = v.served
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
86
|
return out
|
|
@@ -98,10 +109,15 @@ export function updateAssetMap(siteDir, entries) {
|
|
|
98
109
|
for (const [ref, v] of Object.entries(entries || {})) {
|
|
99
110
|
if (!v?.id) continue
|
|
100
111
|
const was = prior[ref]
|
|
112
|
+
// A new `served` fingerprint is a change too: the host serves these bytes at
|
|
113
|
+
// another address now, and the old fingerprint would stop recognizing it. An
|
|
114
|
+
// entry that carries none — a download learns identity, not an upload's URL —
|
|
115
|
+
// keeps the one already recorded for the same bytes.
|
|
116
|
+
const served = v.served || (was && was.id === v.id ? was.served : undefined)
|
|
101
117
|
if (!was) added.push(ref)
|
|
102
|
-
else if (was.id !== v.id) changed.push(ref)
|
|
118
|
+
else if (was.id !== v.id || (served || '') !== (was.served || '')) changed.push(ref)
|
|
103
119
|
else continue
|
|
104
|
-
prior[ref] = { id: v.id, ext: v.ext || '' }
|
|
120
|
+
prior[ref] = { id: v.id, ext: v.ext || '', ...(served ? { served } : {}) }
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
if (!added.length && !changed.length) return { added, changed, written: false }
|
|
@@ -136,6 +152,26 @@ export function refForAssetId(map, id) {
|
|
|
136
152
|
return null
|
|
137
153
|
}
|
|
138
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The fingerprint `assets.json` keeps of the URL a host serves an asset at.
|
|
157
|
+
*
|
|
158
|
+
* A hash, never the URL: it can recognize an address a pull brings back and it
|
|
159
|
+
* cannot be used to compose one — see the header. Prefixed so a reader of the
|
|
160
|
+
* committed file cannot mistake it for something to fetch.
|
|
161
|
+
*
|
|
162
|
+
* ⛔ Why not carry identity on the wire instead, as content images do? A bare string
|
|
163
|
+
* has no object to put `assetId` beside, and folding it into the value (a URL
|
|
164
|
+
* fragment was tried, 2026-09-10) changes what every consumer receives — a
|
|
165
|
+
* foundation that tells video from image by `src.endsWith('.mp4')` would break on
|
|
166
|
+
* a hosted site. This way the wire value is exactly the host's URL.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} url - the serve URL the upload plan returned, verbatim
|
|
169
|
+
* @returns {string} `sha256:<16 hex>`
|
|
170
|
+
*/
|
|
171
|
+
export function servedFingerprint(url) {
|
|
172
|
+
return `sha256:${createHash('sha256').update(String(url)).digest('hex').slice(0, 16)}`
|
|
173
|
+
}
|
|
174
|
+
|
|
139
175
|
/**
|
|
140
176
|
* Restore authored asset paths on a document being projected back to files.
|
|
141
177
|
*
|
|
@@ -188,5 +224,41 @@ export function restoreAssetRefs(document, map) {
|
|
|
188
224
|
for (const v of Object.values(node)) visit(v)
|
|
189
225
|
}
|
|
190
226
|
visit(document)
|
|
227
|
+
|
|
228
|
+
// ⭐ BARE STRINGS — a reference with no object to carry identity beside it
|
|
229
|
+
// (`info.preview`, `info.favicon`, `seo.image`, a section param). The stored value
|
|
230
|
+
// is the serve URL alone, so it is recognized by the fingerprint the push recorded
|
|
231
|
+
// for it (`servedFingerprint`). A string the map has no fingerprint for stays as
|
|
232
|
+
// the URL that works — the same rule as an unknown id above.
|
|
233
|
+
const byServed = new Map()
|
|
234
|
+
for (const [ref, v] of Object.entries(map || {})) {
|
|
235
|
+
if (v?.served && !byServed.has(v.served)) byServed.set(v.served, ref)
|
|
236
|
+
}
|
|
237
|
+
if (byServed.size) {
|
|
238
|
+
const restore = (v) => {
|
|
239
|
+
if (typeof v !== 'string' || !looksLikeAddress(v)) return v
|
|
240
|
+
const ref = byServed.get(servedFingerprint(v))
|
|
241
|
+
if (!ref) return v
|
|
242
|
+
stats.restored++
|
|
243
|
+
return ref
|
|
244
|
+
}
|
|
245
|
+
const walk = (node) => {
|
|
246
|
+
if (Array.isArray(node)) {
|
|
247
|
+
for (let i = 0; i < node.length; i++) {
|
|
248
|
+
if (typeof node[i] === 'string') node[i] = restore(node[i])
|
|
249
|
+
else walk(node[i])
|
|
250
|
+
}
|
|
251
|
+
} else if (node && typeof node === 'object') {
|
|
252
|
+
for (const key of Object.keys(node)) {
|
|
253
|
+
if (typeof node[key] === 'string') node[key] = restore(node[key])
|
|
254
|
+
else walk(node[key])
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
walk(document)
|
|
259
|
+
}
|
|
191
260
|
return stats
|
|
192
261
|
}
|
|
262
|
+
|
|
263
|
+
// Only a string that could be a served address is worth hashing.
|
|
264
|
+
const looksLikeAddress = (v) => v.startsWith('/') || /^https?:\/\//i.test(v)
|
package/src/uwx/index.js
CHANGED
|
@@ -54,7 +54,7 @@ export {
|
|
|
54
54
|
queriesYmlPath,
|
|
55
55
|
QUERIES_YML_RELPATH,
|
|
56
56
|
} from './queries-config.js'
|
|
57
|
-
export { upsertYamlScalar } from './yaml-upsert.js'
|
|
57
|
+
export { upsertYamlScalar, removeYamlScalar } from './yaml-upsert.js'
|
|
58
58
|
export { buildFolderEntity,
|
|
59
59
|
collectFolderItemUuids,
|
|
60
60
|
stampFolderItemUuids
|
|
@@ -83,6 +83,7 @@ export {
|
|
|
83
83
|
refForAssetId,
|
|
84
84
|
restoreAssetRefs,
|
|
85
85
|
ASSET_MAP_FILE,
|
|
86
|
+
servedFingerprint,
|
|
86
87
|
} from './asset-map.js'
|
|
87
88
|
export {
|
|
88
89
|
diffSiteUnits,
|
|
@@ -40,6 +40,7 @@ import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './proje
|
|
|
40
40
|
import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
|
|
41
41
|
import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
|
|
42
42
|
import { isContentBodyField } from './data-schema.js'
|
|
43
|
+
import { unresolveSelfScope } from './self-scope.js'
|
|
43
44
|
import { unwrapLocalized } from './backfill.js'
|
|
44
45
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
45
46
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
@@ -155,29 +156,10 @@ function recordDirFor(siteRoot, model, selfOrg) {
|
|
|
155
156
|
return dirs ? join(siteRoot, ENTITIES_DIR, ...dirs) : null
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* on both ends. `@/article` is a FOUNDATION-RELATIVE alias: the producer resolves
|
|
163
|
-
* it to `@acme/article` before it ships, because the backend resolves Models by
|
|
164
|
-
* name and never mints. So a record authored under `entities/article/` comes back
|
|
165
|
-
* as `@acme/article` and, placed literally, lands under `entities/acme/article/` —
|
|
166
|
-
* a different schema folder, which the next build reads as a different schema.
|
|
167
|
-
*
|
|
168
|
-
* ⚠️ It did not show before records were placed by their model: every record went
|
|
169
|
-
* to `collections/<collection>/` regardless, so the resolution had nowhere to leak.
|
|
170
|
-
*
|
|
171
|
-
* ⭐ The site records its own org at create (`site.yml::$org` — "whose this is"),
|
|
172
|
-
* which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
|
|
173
|
-
* genuinely is that org's, and `@/` would be a lie.
|
|
174
|
-
*/
|
|
175
|
-
export function unresolveSelfScope(model, selfOrg) {
|
|
176
|
-
if (typeof model !== 'string' || !selfOrg) return model
|
|
177
|
-
const org = String(selfOrg).replace(/^@/, '').replace(/\/.*$/, '')
|
|
178
|
-
if (!org) return model
|
|
179
|
-
return model.startsWith(`@${org}/`) ? `@/${model.slice(org.length + 2)}` : model
|
|
180
|
-
}
|
|
159
|
+
// ⚠️ Undoing the producer's self-scope resolution (`unresolveSelfScope`) lives in
|
|
160
|
+
// `./self-scope.js`, beside the forward rule it inverts. It did not show before
|
|
161
|
+
// records were placed by their model: every record went to
|
|
162
|
+
// `collections/<collection>/` regardless, so the resolution had nowhere to leak.
|
|
181
163
|
|
|
182
164
|
// Resolve a record's (collection, slug): the folder index first (authoritative on
|
|
183
165
|
// a read), the record document's `$id` (`<collection>/<slug>`) as a fallback.
|
|
@@ -252,7 +234,16 @@ function isDerivedDeferred(d, dataSchemas) {
|
|
|
252
234
|
return d.deferred.every((f) => a.has(f))
|
|
253
235
|
}
|
|
254
236
|
|
|
255
|
-
function declToFileShape(
|
|
237
|
+
function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
|
|
238
|
+
// ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
|
|
239
|
+
// `schema`. The push qualifies a foundation-relative `@/x` to `@org/x`
|
|
240
|
+
// (`site.js::queriesNested`), and both checks below are keyed by the author's
|
|
241
|
+
// `@/x`: against `@org/x` the query-name default would never match — writing an
|
|
242
|
+
// explicit schema the author never had — and the derived-`deferred` lookup would
|
|
243
|
+
// miss, persisting a derivation into their file (the 2026-08-29 defect).
|
|
244
|
+
const d = selfOrg && typeof wire.schema === 'string'
|
|
245
|
+
? { ...wire, schema: unresolveSelfScope(wire.schema, selfOrg) }
|
|
246
|
+
: wire
|
|
256
247
|
const name = d.name || d.$id
|
|
257
248
|
const decl = {}
|
|
258
249
|
|
|
@@ -328,9 +319,12 @@ function declToFileShape(d, dataSchemas = null) {
|
|
|
328
319
|
* @param {object} params
|
|
329
320
|
* @param {object} params.document - a site-content `$`-document (`{ queries }`)
|
|
330
321
|
* @param {string} params.siteRoot
|
|
322
|
+
* @param {string} [params.org] - the site's own org, so a `schema` the producer
|
|
323
|
+
* qualified from `@/x` is written back as `@/x`. Defaults to `site.yml::$org`,
|
|
324
|
+
* the same default `recordsToProject` places records by.
|
|
331
325
|
* @returns {{ collections?: 'updated'|'unchanged' }}
|
|
332
326
|
*/
|
|
333
|
-
export function declarationsToQueriesYml({ document, siteRoot }) {
|
|
327
|
+
export function declarationsToQueriesYml({ document, siteRoot, org }) {
|
|
334
328
|
const decls = Array.isArray(document?.queries) ? document.queries : []
|
|
335
329
|
const report = {}
|
|
336
330
|
if (decls.length === 0) return report
|
|
@@ -348,10 +342,11 @@ export function declarationsToQueriesYml({ document, siteRoot }) {
|
|
|
348
342
|
siteYml = null
|
|
349
343
|
}
|
|
350
344
|
const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
|
|
345
|
+
const selfOrg = org ?? readSiteOrg(siteRoot)
|
|
351
346
|
|
|
352
347
|
const queries = {}
|
|
353
348
|
for (const d of decls) {
|
|
354
|
-
const { name, decl } = declToFileShape(d, dataSchemas)
|
|
349
|
+
const { name, decl } = declToFileShape(d, dataSchemas, selfOrg)
|
|
355
350
|
if (!name) continue
|
|
356
351
|
queries[name] = decl
|
|
357
352
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from '../site/entity-pool.js'
|
|
56
56
|
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
|
|
57
57
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
58
|
+
import { resolveSelfScope } from './self-scope.js'
|
|
58
59
|
import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
59
60
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
60
61
|
import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
|
|
@@ -631,12 +632,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
631
632
|
// `resolveDeclaration` already matches a fully-qualified name against the
|
|
632
633
|
// foundation's `@/`-keyed `dataSchemas`, so a resolved name looks up correctly and
|
|
633
634
|
// `declaration.name` — the value that becomes `$model` — is the resolved one.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
? `@${selfScopeOrg}/${ref.slice(2)}`
|
|
639
|
-
: ref
|
|
635
|
+
//
|
|
636
|
+
// ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
|
|
637
|
+
// (`site.js::queriesNested`): a query's `schema` must name exactly the Model
|
|
638
|
+
// these records are stored under, so both go through one function with one org.
|
|
640
639
|
// Collections that resolved no data schema (the convention-default soft-skip
|
|
641
640
|
// below) — not synced as folder entities. The composite deploy delivers these
|
|
642
641
|
// statically (the "data ball") instead, so the caller can route them there.
|
|
@@ -647,7 +646,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
647
646
|
const seen = new Set()
|
|
648
647
|
for (const { name, decl } of mapped) {
|
|
649
648
|
const declaredModel = decl.schema || decl.model
|
|
650
|
-
const modelName = resolveSelfScope(declaredModel)
|
|
649
|
+
const modelName = resolveSelfScope(declaredModel, opts.org)
|
|
651
650
|
// Unresolvable `@/` — no org is known. Ship it rather than throwing (a `status`
|
|
652
651
|
// probe on a never-pushed site has no org and must still count), but say so:
|
|
653
652
|
// the backend's refusal names a missing Model and cannot name this cause.
|