@uniweb/build 0.44.5 → 0.46.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 -7
- package/src/prerender.js +222 -69
- package/src/site/content-collector.js +178 -83
- package/src/site/data-fetcher.js +51 -7
- package/src/site/fetch-shapes.js +54 -0
- package/src/site/query-processor.js +35 -6
- package/src/uwx/emit-surface.json +1 -1
- package/src/uwx/project-writer.js +22 -7
- package/src/uwx/site-project.js +42 -21
- package/src/uwx/site.js +31 -18
- package/src/validate-data.js +3 -3
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
25
25
|
import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
|
|
26
26
|
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
27
|
-
import { join, parse, resolve, sep } from 'node:path'
|
|
27
|
+
import { join, parse, relative, resolve, sep } from 'node:path'
|
|
28
28
|
import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
|
|
29
29
|
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
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -175,27 +220,86 @@ function detectVersions(folderNames) {
|
|
|
175
220
|
}
|
|
176
221
|
|
|
177
222
|
/**
|
|
178
|
-
*
|
|
223
|
+
* `query:` — the shorthand for `fetch: { query }`, at every level that declares
|
|
224
|
+
* data: a section's frontmatter, `page.yml`, `folder.yml` and `site.yml`, on the
|
|
225
|
+
* build and on the sync push alike. It names a query, or a list of them — "fetch
|
|
226
|
+
* each", one `content.data` key per name (see `parseFetchConfig` for why a
|
|
227
|
+
* declaration is plural and why that says nothing about request count). Anything
|
|
228
|
+
* richer — a `limit`, a `where`, a source — is `fetch:`.
|
|
179
229
|
*
|
|
180
|
-
* `data
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
230
|
+
* ⛔ `data:` WAS THIS KEY until 2026-09-11 [Diego], and it is REFUSED, not
|
|
231
|
+
* ignored: in frontmatter an unreserved key becomes a section param, so a stale
|
|
232
|
+
* `data:` would render an empty section and say nothing. The word stays where it
|
|
233
|
+
* names the data itself — a section type's `meta.js` `data:` (the shape of each
|
|
234
|
+
* `content.data` key, which fetches nothing) and `content.data`.
|
|
184
235
|
*
|
|
185
|
-
* ⛔
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
236
|
+
* ⛔ `query:` beside `fetch:` is refused too. It was silent — `fetch:` won, and
|
|
237
|
+
* the other declaration was dropped with nothing saying so.
|
|
238
|
+
*
|
|
239
|
+
* @param {Object|null|undefined} config - the level's authored config
|
|
240
|
+
* @param {string} where - the file it was authored in, for the message
|
|
241
|
+
* @returns {Object|Array<Object>|undefined} the level's `fetch:` declaration, as authored
|
|
242
|
+
*/
|
|
243
|
+
export function declaredFetch(config, where) {
|
|
244
|
+
checkDeclaration(config, where)
|
|
245
|
+
return config?.fetch ?? fetchFromQueryShorthand(config?.query)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The refusals `declaredFetch` makes, alone — for a reader that must keep the
|
|
250
|
+
* desugaring inline (the sync push's `settings.fetch`, whose sources
|
|
251
|
+
* `scripts/gen-emit-surface.mjs` reads off the expression).
|
|
189
252
|
*
|
|
190
|
-
* @param {
|
|
253
|
+
* @param {Object|null|undefined} config
|
|
254
|
+
* @param {string} where
|
|
255
|
+
*/
|
|
256
|
+
export function checkDeclaration(config, where) {
|
|
257
|
+
if (!config || typeof config !== 'object') return
|
|
258
|
+
if (config.data !== undefined) {
|
|
259
|
+
const other = config.query !== undefined ? 'query' : config.fetch !== undefined ? 'fetch' : null
|
|
260
|
+
throw new Error(
|
|
261
|
+
other
|
|
262
|
+
? `[uniweb] ${where}: \`data:\` is retired, and this file already declares \`${other}:\` — delete the \`data:\` line.`
|
|
263
|
+
: `[uniweb] ${where}: \`data:\` is retired — the shorthand for \`fetch: { query }\` is now \`query:\`. ` +
|
|
264
|
+
`Write \`query: ${formatQueryNames(config.data)}\`.`
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
if (config.query !== undefined && config.fetch !== undefined) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[uniweb] ${where}: declare \`query:\` or \`fetch:\`, not both — \`query: x\` is the shorthand for \`fetch: { query: x }\`.`
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
if (config.query !== undefined && !isQueryNames(config.query)) {
|
|
273
|
+
const declaring = config.query && typeof config.query === 'object' && !Array.isArray(config.query)
|
|
274
|
+
throw new Error(
|
|
275
|
+
`[uniweb] ${where}: \`query:\` takes a query name or a list of names; anything richer is \`fetch:\` — ` +
|
|
276
|
+
`e.g. \`fetch: { query: articles, limit: 3 }\`.` +
|
|
277
|
+
(declaring ? ' Declaring queries? That is `queries:` (in site.yml) or queries.yml.' : '')
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Desugar a `query:` value: `team` → `{ query: 'team' }`, `[team, articles]` →
|
|
284
|
+
* one config per name. ⛔ Before 2026-09-02 a list kept `[0]` and dropped the rest
|
|
285
|
+
* silently — an author writing a list got one dataset and a section rendering
|
|
286
|
+
* empty.
|
|
287
|
+
*
|
|
288
|
+
* @param {string|Array<string>|undefined|null} query - already checked (`checkDeclaration`)
|
|
191
289
|
* @returns {Object|Array<Object>|undefined}
|
|
192
290
|
*/
|
|
193
|
-
export function
|
|
194
|
-
if (
|
|
195
|
-
if (Array.isArray(
|
|
196
|
-
return { query
|
|
291
|
+
export function fetchFromQueryShorthand(query) {
|
|
292
|
+
if (query === undefined || query === null) return undefined
|
|
293
|
+
if (Array.isArray(query)) return query.map((name) => ({ query: name }))
|
|
294
|
+
return { query }
|
|
197
295
|
}
|
|
198
296
|
|
|
297
|
+
const isQueryName = (name) => typeof name === 'string' && name.trim() !== ''
|
|
298
|
+
const isQueryNames = (value) =>
|
|
299
|
+
isQueryName(value) || (Array.isArray(value) && value.length > 0 && value.every(isQueryName))
|
|
300
|
+
const formatQueryNames = (value) =>
|
|
301
|
+
isQueryNames(value) ? (Array.isArray(value) ? `[${value.join(', ')}]` : value) : '<name>'
|
|
302
|
+
|
|
199
303
|
/**
|
|
200
304
|
* Build version metadata from detected versions and page.yml config
|
|
201
305
|
* @param {Array<Object>} detectedVersions - Detected version infos
|
|
@@ -235,7 +339,7 @@ function buildVersionMetadata(detectedVersions, pageConfig = {}) {
|
|
|
235
339
|
* this function reads `site.yml`, `page.yml`, `folder.yml`, `theme.yml` and
|
|
236
340
|
* every section's frontmatter — i.e. every configuration surface an author
|
|
237
341
|
* writes. So a single typo silently discarded that file's entire contribution:
|
|
238
|
-
* page order, nesting, `sections:`, `
|
|
342
|
+
* page order, nesting, `sections:`, `query:` declarations, theme. **The build
|
|
239
343
|
* succeeded and shipped a site missing what the author asked for**, with one
|
|
240
344
|
* line on stderr that named no file.
|
|
241
345
|
*
|
|
@@ -866,7 +970,6 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
|
|
|
866
970
|
lastModified: fileStat.mtime?.toISOString() || null,
|
|
867
971
|
isDynamic: false,
|
|
868
972
|
paramName: null,
|
|
869
|
-
parentSchema: null,
|
|
870
973
|
version: null,
|
|
871
974
|
versionMeta: null,
|
|
872
975
|
versionScope: null,
|
|
@@ -921,7 +1024,9 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
921
1024
|
console.warn(`[content-collector] ${err.message}`)
|
|
922
1025
|
}
|
|
923
1026
|
|
|
924
|
-
|
|
1027
|
+
// `query`, `fetch` and `data` are never params: `query:` / `fetch:` declare the
|
|
1028
|
+
// section's own data, and a leftover `data:` is refused (`declaredFetch`).
|
|
1029
|
+
const { type, preset, input, props, fetch, query, data, id: frontmatterId, ...params } = frontMatter
|
|
925
1030
|
|
|
926
1031
|
// Convert markdown to ProseMirror
|
|
927
1032
|
const proseMirrorContent = markdownToProseMirror(markdown)
|
|
@@ -929,28 +1034,13 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
929
1034
|
// Extract @ component references → insets (mutates doc)
|
|
930
1035
|
const insets = extractInsets(proseMirrorContent)
|
|
931
1036
|
|
|
932
|
-
// `
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
// (`meta.inheritData === false` → deliver nothing). It never consumed list
|
|
940
|
-
// elements. What actually happens is that delivery is default-on and
|
|
941
|
-
// collect-all: `EntityStore._getRequestedSchemas` returns `[]`, so a section
|
|
942
|
-
// receives EVERY fetch config in the section → page → site cascade whether or
|
|
943
|
-
// not it named any of them.
|
|
944
|
-
//
|
|
945
|
-
// ⇒ So `articles` arrives only if something ELSE already declared a fetch for
|
|
946
|
-
// it — in which case this section would have received it anyway. **Naming it
|
|
947
|
-
// here contributes nothing.** Measured 2026-09-02: `data: [team, articles]`
|
|
948
|
-
// with no other declaration yields exactly one config, `team`, and the array
|
|
949
|
-
// is not carried forward on the section, so nothing downstream can recover it.
|
|
950
|
-
//
|
|
951
|
-
// ⚠️ The list form appears in no `docs/` page, so nothing promises it works.
|
|
952
|
-
// Whether it should mean "fetch each" or be refused outright is undecided.
|
|
953
|
-
const resolvedFetch = fetch || fetchFromDataShorthand(data)
|
|
1037
|
+
// `query: team` → `fetch: { query: team }`; a list, one config per name, each
|
|
1038
|
+
// delivered under its own `content.data` key — the one helper every level uses.
|
|
1039
|
+
// Unrelated to a section type's `meta.js` `data:`, which declares the SHAPE of
|
|
1040
|
+
// each `content.data` key and fetches nothing: delivery is default-on, so a
|
|
1041
|
+
// section receives every fetch in the section → page → site cascade whether or
|
|
1042
|
+
// not it names one here.
|
|
1043
|
+
const resolvedFetch = declaredFetch({ fetch, query, data }, relative(siteRoot, filePath))
|
|
954
1044
|
|
|
955
1045
|
// Stable ID for scroll targeting: frontmatter id > filename-derived > null
|
|
956
1046
|
// This ID is stable across reordering (unlike the positional id)
|
|
@@ -1467,12 +1557,19 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1467
1557
|
// Determine route
|
|
1468
1558
|
// Index pages get the parent route as their canonical route (no dual routes)
|
|
1469
1559
|
// sourcePath stores the original folder-based path for ancestor checking
|
|
1470
|
-
|
|
1471
|
-
|
|
1560
|
+
// ⭐ A page is PARAMETRIC when its folder is a bracket name or it sits inside
|
|
1561
|
+
// one (ruled 2026-09-11 [Diego]): `pages/members/[slug]/cv/` is `/members/:slug/cv`
|
|
1562
|
+
// and binds its ancestor's `slug`. Every lane — the SPA, the prefetch, the static
|
|
1563
|
+
// build — tells a parametric page by the parameter in its route; this flag says
|
|
1564
|
+
// the same thing to a consumer that reads the flag.
|
|
1565
|
+
const isBracket = isDynamicRoute(pageName)
|
|
1566
|
+
const inheritedParam = isBracket ? null : inheritedRouteParam(parentRoute)
|
|
1567
|
+
const isDynamic = isBracket || inheritedParam !== null
|
|
1568
|
+
const paramName = isBracket ? extractRouteParam(pageName) : inheritedParam
|
|
1472
1569
|
|
|
1473
1570
|
// First, calculate the folder-based route (what the route would be without index handling)
|
|
1474
1571
|
let folderRoute
|
|
1475
|
-
if (
|
|
1572
|
+
if (isBracket) {
|
|
1476
1573
|
// Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
|
|
1477
1574
|
// /blog/[...path] → /blog/:path* — the one multi-segment token the matcher knows.
|
|
1478
1575
|
const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
|
|
@@ -1500,23 +1597,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1500
1597
|
const layoutObj = mergeLayoutConfig(inheritedLayout, normalizeLayoutConfig(layoutConfig))
|
|
1501
1598
|
const resolvedLayoutName = layoutObj.name || null
|
|
1502
1599
|
|
|
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
|
-
}
|
|
1600
|
+
// ⛔ NO `parentSchema`. Which query a parametric page's URL names one record of
|
|
1601
|
+
// — its ROUTE QUERY — is worked out where it is read, by one function every lane
|
|
1602
|
+
// calls (`routeQuery`, `@uniweb/core/fetch-config`): the page's own query, its
|
|
1603
|
+
// parent's, the site's, or its sections' shared key. This emitted a copy chosen
|
|
1604
|
+
// by another rule (the closest ancestor with a query at ANY depth, never the
|
|
1605
|
+
// page's own or the site's), so the URL narrowed nothing, or a key no section
|
|
1606
|
+
// received — measured 2026-09-10. Removed 2026-09-11 [Diego].
|
|
1520
1607
|
|
|
1521
1608
|
return {
|
|
1522
1609
|
page: {
|
|
@@ -1535,10 +1622,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1535
1622
|
: {}),
|
|
1536
1623
|
lastModified: lastModified?.toISOString(),
|
|
1537
1624
|
|
|
1538
|
-
//
|
|
1625
|
+
// Parametric route metadata
|
|
1539
1626
|
isDynamic,
|
|
1540
|
-
paramName, // e.g., "slug" from [slug]
|
|
1541
|
-
parentSchema, // e.g., "articles" - the data array to iterate over
|
|
1627
|
+
paramName, // e.g., "slug" from [slug]; a nested page's ancestor's
|
|
1542
1628
|
|
|
1543
1629
|
// Version metadata (if within a versioned section)
|
|
1544
1630
|
version: versionContext?.version || null,
|
|
@@ -1579,12 +1665,8 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1579
1665
|
priority: seo.priority || null
|
|
1580
1666
|
},
|
|
1581
1667
|
|
|
1582
|
-
// Data fetching
|
|
1583
|
-
|
|
1584
|
-
// data: team → fetch: { query: team }
|
|
1585
|
-
fetch: parseFetchConfig(
|
|
1586
|
-
pageConfig.fetch || fetchFromDataShorthand(pageConfig.data)
|
|
1587
|
-
),
|
|
1668
|
+
// Data fetching — `fetch:`, or the `query:` shorthand (`declaredFetch`)
|
|
1669
|
+
fetch: parseFetchConfig(declaredFetch(pageConfig, relative(siteRoot, join(pagePath, 'page.yml')))),
|
|
1588
1670
|
|
|
1589
1671
|
hasContent: hierarchicalSections.length > 0,
|
|
1590
1672
|
sections: hierarchicalSections
|
|
@@ -1872,6 +1954,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1872
1954
|
// Process subdirectories
|
|
1873
1955
|
for (const folder of orderedFolders) {
|
|
1874
1956
|
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
1957
|
+
assertRouteFolder(entry, parentRoute)
|
|
1875
1958
|
const isIndex = entry === indexName
|
|
1876
1959
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
1877
1960
|
|
|
@@ -1930,7 +2013,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1930
2013
|
lastModified: null,
|
|
1931
2014
|
isDynamic: false,
|
|
1932
2015
|
paramName: null,
|
|
1933
|
-
parentSchema: null,
|
|
1934
2016
|
version: versionContext?.version || null,
|
|
1935
2017
|
versionMeta: versionContext?.versionMeta || null,
|
|
1936
2018
|
versionScope: versionContext?.scope || null,
|
|
@@ -1947,7 +2029,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1947
2029
|
changefreq: dirConfig.seo?.changefreq || null,
|
|
1948
2030
|
priority: dirConfig.seo?.priority || null
|
|
1949
2031
|
},
|
|
1950
|
-
|
|
2032
|
+
// ⭐ `declaredFetch`, as every other level: this read `dirConfig.fetch`
|
|
2033
|
+
// alone until 2026-09-11, so a container's `folder.yml` shorthand reached
|
|
2034
|
+
// a backend on `push` and was dropped from a static build.
|
|
2035
|
+
fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
|
|
1951
2036
|
hasContent: false,
|
|
1952
2037
|
sections: [],
|
|
1953
2038
|
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
@@ -2001,6 +2086,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2001
2086
|
// Second pass: process each page folder
|
|
2002
2087
|
for (const folder of orderedFolders) {
|
|
2003
2088
|
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
2089
|
+
assertRouteFolder(entry, parentRoute)
|
|
2004
2090
|
const isIndex = entry === indexPageName
|
|
2005
2091
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
2006
2092
|
|
|
@@ -2024,7 +2110,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2024
2110
|
lastModified: null,
|
|
2025
2111
|
isDynamic: false,
|
|
2026
2112
|
paramName: null,
|
|
2027
|
-
parentSchema: null,
|
|
2028
2113
|
version: versionContext?.version || null,
|
|
2029
2114
|
versionMeta: versionContext?.versionMeta || null,
|
|
2030
2115
|
versionScope: versionContext?.scope || null,
|
|
@@ -2039,7 +2124,12 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2039
2124
|
changefreq: dirConfig.seo?.changefreq || null,
|
|
2040
2125
|
priority: dirConfig.seo?.priority || null
|
|
2041
2126
|
},
|
|
2042
|
-
|
|
2127
|
+
// ⭐ The folder's own declaration, as the folder-mode container above and
|
|
2128
|
+
// every other level read it. ⛔ This was `fetch: null` until 2026-09-11:
|
|
2129
|
+
// a `folder.yml` here lost its `fetch:` (and its shorthand) on a static
|
|
2130
|
+
// build while `push` carried it, so its pages had the folder's data on a
|
|
2131
|
+
// hosted site and none on an exported one.
|
|
2132
|
+
fetch: parseFetchConfig(declaredFetch(dirConfig, relative(siteRoot, join(entryPath, 'folder.yml')))) || null,
|
|
2043
2133
|
hasContent: false,
|
|
2044
2134
|
sections: [],
|
|
2045
2135
|
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
@@ -2051,8 +2141,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2051
2141
|
pages.push(containerPage)
|
|
2052
2142
|
}
|
|
2053
2143
|
|
|
2144
|
+
// The container's own fetch config, or the parent's — as the folder-mode
|
|
2145
|
+
// container above passes it.
|
|
2054
2146
|
const childDirPath = mounts?.get(entry) || entryPath
|
|
2055
|
-
const
|
|
2147
|
+
const containerFetch = containerPage.fetch || parentFetch
|
|
2148
|
+
const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, 'pages', null, effectiveLayout)
|
|
2056
2149
|
pages.push(...subResult.pages)
|
|
2057
2150
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
2058
2151
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
@@ -2616,7 +2709,9 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2616
2709
|
// `publishLanguages` is authoring/publish intent — it has no runtime
|
|
2617
2710
|
// consumer and never ships in a payload (the visitor runtime is
|
|
2618
2711
|
// list-unaware; the sync lane reads site.yml directly, not this output).
|
|
2619
|
-
|
|
2712
|
+
// The `query:` shorthand ships as `config.fetch`, desugared below; carried raw
|
|
2713
|
+
// it would sit beside `config.queries`, the declarations, and read as one.
|
|
2714
|
+
const { publishLanguages: _publishLanguages, query: _query, ...runtimeSiteConfig } = siteConfig
|
|
2620
2715
|
// ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
|
|
2621
2716
|
// `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
|
|
2622
2717
|
// that a visitor can fetch. They have no runtime reader (nothing in core, runtime
|
|
@@ -2660,7 +2755,7 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2660
2755
|
`${distinctFailures.length} file${distinctFailures.length === 1 ? '' : 's'} could not be parsed as YAML:\n` +
|
|
2661
2756
|
`${lines.join('\n')}\n\n` +
|
|
2662
2757
|
` Each one contributed NOTHING to this build — page order, nesting,\n` +
|
|
2663
|
-
` sections:,
|
|
2758
|
+
` sections:, query: and theme settings in these files were dropped.\n` +
|
|
2664
2759
|
` Fix them and rebuild; the dev server reports the same files without failing.`
|
|
2665
2760
|
)
|
|
2666
2761
|
}
|
|
@@ -2680,13 +2775,12 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2680
2775
|
...(publishFilterActive && Array.isArray(siteConfig.languages)
|
|
2681
2776
|
? { languages: publishable }
|
|
2682
2777
|
: {}),
|
|
2683
|
-
// ⛔ `
|
|
2778
|
+
// ⛔ `query:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
|
|
2684
2779
|
// This read `siteConfig.fetch` alone until 2026-09-09, so a site-level
|
|
2685
|
-
//
|
|
2686
|
-
//
|
|
2687
|
-
//
|
|
2688
|
-
|
|
2689
|
-
fetch: parseFetchConfig(siteConfig.fetch || fetchFromDataShorthand(siteConfig.data)),
|
|
2780
|
+
// shorthand reached a backend on the sync lane and was silently ignored on
|
|
2781
|
+
// a static build — the works-on-one-lane shape. Every level reads it
|
|
2782
|
+
// through `declaredFetch` now.
|
|
2783
|
+
fetch: parseFetchConfig(declaredFetch(siteConfig, 'site.yml')),
|
|
2690
2784
|
fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
|
|
2691
2785
|
// NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
|
|
2692
2786
|
// Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
|
|
@@ -2835,6 +2929,7 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
|
|
|
2835
2929
|
}
|
|
2836
2930
|
|
|
2837
2931
|
export {
|
|
2932
|
+
assertRouteFolder,
|
|
2838
2933
|
buildRouteTranslations,
|
|
2839
2934
|
extractItemName,
|
|
2840
2935
|
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
|
|
@@ -234,9 +274,9 @@ export function toFetchList(fetch) {
|
|
|
234
274
|
}
|
|
235
275
|
|
|
236
276
|
/**
|
|
237
|
-
* Parse a `fetch:` (or desugared `
|
|
277
|
+
* Parse a `fetch:` (or desugared `query:`) declaration.
|
|
238
278
|
*
|
|
239
|
-
* ⭐ **A LIST MEANS "FETCH EACH".** `
|
|
279
|
+
* ⭐ **A LIST MEANS "FETCH EACH".** `query: [team, articles]` declares two needs
|
|
240
280
|
* and they land under two keys in `content.data` — a component reads
|
|
241
281
|
* `content.data.team` and `content.data.articles` independently, so the
|
|
242
282
|
* declaration is plural by necessity.
|
|
@@ -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,
|
package/src/site/fetch-shapes.js
CHANGED
|
@@ -69,3 +69,57 @@ export function authorableFetch(fetch) {
|
|
|
69
69
|
}
|
|
70
70
|
return out
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
// ── `query:` or `fetch:` — the key a projection writes back ─────────────────────
|
|
74
|
+
//
|
|
75
|
+
// An author declares a level's data with `fetch:` or with its shorthand, `query:`
|
|
76
|
+
// (`query: team` ≡ `fetch: { query: team }`). The wire carries only the desugared
|
|
77
|
+
// form, so it cannot say which key was typed — and a pull must give back the
|
|
78
|
+
// authored KEY as well as the value (the round-trip law of the sync format). So a
|
|
79
|
+
// projection writes back the key the file already uses, and a file it creates gets
|
|
80
|
+
// `query:` whenever the declaration is nothing but query names — the form the
|
|
81
|
+
// docs teach. The declaration keys are one group: exactly one is written, and a
|
|
82
|
+
// file holding both is refused by the build.
|
|
83
|
+
|
|
84
|
+
/** The keys that declare a level's data: the long form, its shorthand, and the retired shorthand. */
|
|
85
|
+
export const DECLARATION_KEYS = Object.freeze(['query', 'fetch', 'data'])
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The query names a declaration consists of, when that is ALL it says — what
|
|
89
|
+
* `query:` can express. Keys the build derives or defaults beside a query (`path`,
|
|
90
|
+
* `url`, `as` equal to the name, `prerender: true`, `merge: false`) say nothing an
|
|
91
|
+
* author wrote; any other key makes it a `fetch:`.
|
|
92
|
+
*
|
|
93
|
+
* @param {object|object[]} fetch - a declaration (or a list) off the wire
|
|
94
|
+
* @returns {string|string[]|null} the name(s), or null when `query:` cannot say it
|
|
95
|
+
*/
|
|
96
|
+
export function queryNamesOf(fetch) {
|
|
97
|
+
const nameOf = (one) => {
|
|
98
|
+
if (!one || typeof one !== 'object' || typeof one.query !== 'string' || one.query === '') return null
|
|
99
|
+
for (const [key, value] of Object.entries(one)) {
|
|
100
|
+
if (key === 'query' || key === 'path' || key === 'url') continue
|
|
101
|
+
if (key === 'as' && value === one.query) continue
|
|
102
|
+
if (key === 'prerender' && value === true) continue
|
|
103
|
+
if (key === 'merge' && value === false) continue
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
return one.query
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(fetch)) return nameOf(fetch)
|
|
109
|
+
const names = fetch.map(nameOf)
|
|
110
|
+
return names.length > 0 && names.every((n) => n !== null) ? names : null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The declaration to write back, and under which key.
|
|
115
|
+
*
|
|
116
|
+
* @param {object|object[]} wireFetch - the level's `fetch` off the wire
|
|
117
|
+
* @param {object|null} [existing] - the authored file's current keys; null for a new file
|
|
118
|
+
* @returns {{ key: 'query'|'fetch', value: string|string[]|object|object[] }}
|
|
119
|
+
*/
|
|
120
|
+
export function authorableDeclaration(wireFetch, existing = null) {
|
|
121
|
+
const fetch = Array.isArray(wireFetch) ? wireFetch.map((one) => authorableFetch(one)) : authorableFetch(wireFetch)
|
|
122
|
+
const names = queryNamesOf(fetch)
|
|
123
|
+
const typedFetch = !!existing && typeof existing === 'object' && existing.fetch !== undefined
|
|
124
|
+
return names !== null && !typedFetch ? { key: 'query', value: names } : { key: 'fetch', value: fetch }
|
|
125
|
+
}
|