@uniweb/build 0.37.1 → 0.39.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 +8 -10
- 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 +10 -5
- package/src/uwx/folder.js +27 -14
- package/src/uwx/foundation-schema.js +10 -3
- package/src/uwx/records-project.js +19 -13
- package/src/uwx/records.js +24 -10
- 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/uwx/sync-package.js +1 -0
- package/src/dev-backend.js +0 -247
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'
|
|
@@ -143,7 +143,7 @@ export async function readRecordsConfig(siteRoot) {
|
|
|
143
143
|
*
|
|
144
144
|
* ⛔ ONE PLACEMENT PER ENTITY. Two entries matching one file is a hard error, not
|
|
145
145
|
* a second placement. The wire could carry many-to-many — `folder.js` nests, and
|
|
146
|
-
* placements are keyed by their `
|
|
146
|
+
* placements are keyed by their `name` chain — but `core/src/where.js`'s
|
|
147
147
|
* `matchUnder` is STRING-ONLY, so a record with two paths would match nothing
|
|
148
148
|
* under `where: { path: { under: … } }`, silently. Widening `under` is a
|
|
149
149
|
* predicate the backend also evaluates natively, so it is a cross-lane change to
|
|
@@ -184,7 +184,7 @@ export function resolveFolder(entries, pool) {
|
|
|
184
184
|
const slug = slugForEntity(entity)
|
|
185
185
|
const path = pathSegs.join('/')
|
|
186
186
|
placements.set(key, { entity, path, slug })
|
|
187
|
-
return { kind: 'ref',
|
|
187
|
+
return { kind: 'ref', name: slug, $entityId: key }
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
const resolveEntry = (entry, pathSegs, index, trail) => {
|
|
@@ -246,8 +246,13 @@ export function resolveFolder(entries, pool) {
|
|
|
246
246
|
errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
|
|
247
247
|
return []
|
|
248
248
|
}
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
// ⭐ `name` is the handle (the URL segment, sibling-unique); `label` is the
|
|
250
|
+
// display text. The store renamed the pair on 2026-09-04 — `path_segment` →
|
|
251
|
+
// `name`, and the old `name` (display) → `label` — so one word means one
|
|
252
|
+
// thing from records.yml (`folder:` / `label:`) to the wire to the door's
|
|
253
|
+
// `$name`.
|
|
254
|
+
const branch = { kind: 'branch', name: segment }
|
|
255
|
+
if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
|
|
251
256
|
const kids = Array.isArray(entry.records) ? entry.records : []
|
|
252
257
|
if (kids.length === 0) {
|
|
253
258
|
warnings.push(
|
package/src/uwx/folder.js
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
// - `contents` is the self-nesting tree (an array), nesting via `$children` — the
|
|
8
8
|
// same mechanism site-content pages/sections use. Each node holds REFERENCES,
|
|
9
9
|
// never content:
|
|
10
|
-
// - a LEAF references one record entity: `{ kind: 'ref',
|
|
11
|
-
//
|
|
10
|
+
// - a LEAF references one record entity: `{ kind: 'ref', name, ... }` with
|
|
11
|
+
// `entry: <uuid>` once the record was minted (back-filled into its file),
|
|
12
12
|
// or `$ref: "<id>"` while brand-new (resolved within this payload).
|
|
13
|
-
// - a BRANCH is a sub-folder: `{ kind: 'branch',
|
|
13
|
+
// - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, $children }`.
|
|
14
|
+
//
|
|
15
|
+
// ⭐ `name` IS THE HANDLE — the URL segment, sibling-unique, the door's `$name` —
|
|
16
|
+
// and `label` is the display text, a localized map (`{ en: "Blog" }`). The store
|
|
17
|
+
// renamed the pair on 2026-09-04 (`path_segment` → `name`; the old display `name`
|
|
18
|
+
// → `label`); this emitter writes the new shape only and the pull reader
|
|
19
|
+
// (`records-project.js`) reads the new shape only. No alias on either side: the
|
|
20
|
+
// old key's PRESENCE was the version signal, and there is no population to
|
|
21
|
+
// carry.
|
|
14
22
|
//
|
|
15
23
|
// ⭐ THE ORGANIZATION IS AUTHORED, IN `records.yml`, AND IT IS THE ONLY SOURCE.
|
|
16
24
|
// It used to be DERIVED — one branch per collection, mirroring the `collections/`
|
|
@@ -42,7 +50,7 @@ export const FOLDER_ENTITY_KEY = '@folder'
|
|
|
42
50
|
// it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
|
|
43
51
|
// resolution (a registry data-schema read) as a follow-up.
|
|
44
52
|
function refLeaf(entity) {
|
|
45
|
-
const leaf = { kind: 'ref',
|
|
53
|
+
const leaf = { kind: 'ref', name: entity.slug }
|
|
46
54
|
if (entity.uuid) leaf.entry = { model: entity.model, entity: entity.uuid }
|
|
47
55
|
else leaf.$ref = entity.id // the payload-local handle
|
|
48
56
|
return leaf
|
|
@@ -60,13 +68,16 @@ function refLeaf(entity) {
|
|
|
60
68
|
* @param {Map<string, object>} byEntityId - record entities, keyed by pool id
|
|
61
69
|
* @param {string[]} missing - collects ids that resolved to no entity
|
|
62
70
|
*/
|
|
63
|
-
function contentsFromNodes(nodes, byEntityId, missing) {
|
|
71
|
+
function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
64
72
|
const out = []
|
|
65
73
|
for (const node of nodes || []) {
|
|
66
74
|
if (node.kind === 'branch') {
|
|
67
|
-
const branch = { kind: 'branch',
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
const branch = { kind: 'branch', name: node.name }
|
|
76
|
+
// The display text is a LOCALIZED field on the wire — a `{ locale: value }`
|
|
77
|
+
// map, like every localized scalar this producer sends — keyed by the
|
|
78
|
+
// site's source locale.
|
|
79
|
+
if (node.label !== undefined) branch.label = { [sourceLocale]: String(node.label) }
|
|
80
|
+
branch.$children = contentsFromNodes(node.$children, byEntityId, missing, sourceLocale)
|
|
70
81
|
out.push(branch)
|
|
71
82
|
continue
|
|
72
83
|
}
|
|
@@ -82,7 +93,7 @@ function contentsFromNodes(nodes, byEntityId, missing) {
|
|
|
82
93
|
|
|
83
94
|
/**
|
|
84
95
|
* Walk a folder document's `contents` tree, visiting every item with the
|
|
85
|
-
* slash-joined `
|
|
96
|
+
* slash-joined `name` chain that addresses it.
|
|
86
97
|
*
|
|
87
98
|
* ⛔ IT MUST RECURSE INTO `$children`. `contents` is SELF-NESTING: a walk of the
|
|
88
99
|
* top level sees the branches and misses every record under them — which is 6 of
|
|
@@ -92,7 +103,7 @@ function contentsFromNodes(nodes, byEntityId, missing) {
|
|
|
92
103
|
function walkFolderItems(contents, cb, prefix = '') {
|
|
93
104
|
for (const item of contents || []) {
|
|
94
105
|
if (!item || typeof item !== 'object') continue
|
|
95
|
-
const seg = typeof item.
|
|
106
|
+
const seg = typeof item.name === 'string' ? item.name : null
|
|
96
107
|
const path = seg ? (prefix ? `${prefix}/${seg}` : seg) : prefix
|
|
97
108
|
if (seg) cb(path, item)
|
|
98
109
|
walkFolderItems(item.$children, cb, path)
|
|
@@ -102,8 +113,8 @@ function walkFolderItems(contents, cb, prefix = '') {
|
|
|
102
113
|
/**
|
|
103
114
|
* Harvest per-item identity from the folder document the backend returns.
|
|
104
115
|
*
|
|
105
|
-
* ⭐ THE KEY IS THE `
|
|
106
|
-
*
|
|
116
|
+
* ⭐ THE KEY IS THE `name` CHAIN, and it is the right one because the backend's
|
|
117
|
+
* own model declares `name` SIBLING-UNIQUE — so the chain is
|
|
107
118
|
* unique within the folder, stable across pushes, and derivable identically on
|
|
108
119
|
* both sides without either lane holding the other's ids.
|
|
109
120
|
*
|
|
@@ -163,9 +174,11 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
|
|
|
163
174
|
* @param {Record<string,string>} [params.itemUuids] - path → `$uuid`, harvested
|
|
164
175
|
* from the folder document a previous push returned. Absent on a first
|
|
165
176
|
* push, where every item is genuinely new.
|
|
177
|
+
* @param {string} [params.sourceLocale='en'] - the locale a branch `label` is
|
|
178
|
+
* keyed under on the wire
|
|
166
179
|
* @returns {{ id, uuid, model, file, document, warnings }|null}
|
|
167
180
|
*/
|
|
168
|
-
export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null }) {
|
|
181
|
+
export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null, sourceLocale = 'en' }) {
|
|
169
182
|
// ⛔ `missing` AND `empty` ARE DIFFERENT, AND THE ASYMMETRY IS DELIBERATE.
|
|
170
183
|
//
|
|
171
184
|
// no records.yml → null. INERT: nothing is sent, and the server's
|
|
@@ -186,7 +199,7 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
|
|
|
186
199
|
for (const e of recordEntities || []) byEntityId.set(e.id, e)
|
|
187
200
|
|
|
188
201
|
const missing = []
|
|
189
|
-
const contents = contentsFromNodes(folderNodes, byEntityId, missing)
|
|
202
|
+
const contents = contentsFromNodes(folderNodes, byEntityId, missing, sourceLocale)
|
|
190
203
|
// ⚠️ `id` IS THE ENTITY'S POOL PATH, NOT A FOLDER PATH — say so, because the two
|
|
191
204
|
// read identically and a reader who takes it for a placement concludes the
|
|
192
205
|
// emitter is dropping a branch it never had. *(Measured 2026-08-31: the backend
|
|
@@ -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 }
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Identity & placement. A record's on-disk home is `(collection, slug)`:
|
|
8
8
|
// - `slug` and `collection` come from the FOLDER document — each ref leaf is
|
|
9
|
-
// `{ entry: { model, entity: <uuid> },
|
|
10
|
-
// (its `$children`) whose `
|
|
9
|
+
// `{ entry: { model, entity: <uuid> }, name: <slug> }` inside a branch
|
|
10
|
+
// (its `$children`) whose `name` names the folder it was placed in.
|
|
11
11
|
// The folder is the authoritative organization on a read (the record
|
|
12
12
|
// document's own `$id` envelope is not guaranteed to be echoed back), with
|
|
13
13
|
// the record document's `$id` (its pool identity) as a fallback when present.
|
|
@@ -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 { unwrapLocalized } from './backfill.js'
|
|
43
44
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
44
45
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
45
46
|
|
|
@@ -111,9 +112,13 @@ function briefHasContentBody(declaration) {
|
|
|
111
112
|
|
|
112
113
|
// Build `uuid → { collection, slug }` from the folder document's ref leaves. The
|
|
113
114
|
// folder is a self-nesting tree under `contents`, nesting via `$children` (the
|
|
114
|
-
// site-content invariant — folder.js). A leaf sits in a branch whose
|
|
115
|
-
//
|
|
116
|
-
//
|
|
115
|
+
// site-content invariant — folder.js). A leaf sits in a branch whose `name` is
|
|
116
|
+
// the collection; the leaf's `name` is the slug (the handle) and its `entry` is
|
|
117
|
+
// the entity_ref open form `{ model, entity: <uuid> }`. ⛔ `path_segment` is not
|
|
118
|
+
// read: the store renamed it on 2026-09-04 and a pull emits the new shape only —
|
|
119
|
+
// a reader that kept the old key would index every record as
|
|
120
|
+
// `{ folderPath: null, slug: undefined }` and rewrite records.yml with
|
|
121
|
+
// `folder: undefined` branches (measured on this reader, 2026-09-04). Nested branches
|
|
117
122
|
// are walked; the collection is the NEAREST enclosing branch segment (correct for
|
|
118
123
|
// the default one-branch-per-collection org; a deeply nested virtual org may
|
|
119
124
|
// differ — see the module header).
|
|
@@ -122,11 +127,11 @@ function indexFolder(folderDoc) {
|
|
|
122
127
|
const walk = (nodes, folderPath) => {
|
|
123
128
|
for (const node of nodes || []) {
|
|
124
129
|
if (node?.kind === 'branch') {
|
|
125
|
-
walk(node.$children, node.
|
|
130
|
+
walk(node.$children, node.name ?? folderPath)
|
|
126
131
|
} else if (node?.kind === 'ref' && node.entry) {
|
|
127
132
|
// `entry` is `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
|
|
128
133
|
const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
|
|
129
|
-
if (uuid) byUuid.set(uuid, { folderPath, slug: node.
|
|
134
|
+
if (uuid) byUuid.set(uuid, { folderPath, slug: node.name })
|
|
130
135
|
}
|
|
131
136
|
}
|
|
132
137
|
}
|
|
@@ -381,7 +386,7 @@ export function declarationsToQueriesYml({ document, siteRoot }) {
|
|
|
381
386
|
* each record landed with.
|
|
382
387
|
* @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
|
|
383
388
|
*/
|
|
384
|
-
export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
|
|
389
|
+
export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
|
|
385
390
|
const warnings = []
|
|
386
391
|
|
|
387
392
|
const walk = (nodes) => {
|
|
@@ -389,10 +394,11 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
|
|
|
389
394
|
for (const node of nodes || []) {
|
|
390
395
|
if (!node || typeof node !== 'object') continue
|
|
391
396
|
if (node.kind === 'branch') {
|
|
392
|
-
const entry = { folder: node.
|
|
397
|
+
const entry = { folder: node.name }
|
|
393
398
|
// Only a BRANCH takes a label. A record carries its own title; the folder
|
|
394
|
-
// does not caption its rows.
|
|
395
|
-
|
|
399
|
+
// does not caption its rows. On the wire the label is a localized map;
|
|
400
|
+
// records.yml carries the source-locale string (a bare string passes).
|
|
401
|
+
if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
|
|
396
402
|
entry.records = walk(node.$children)
|
|
397
403
|
out.push(entry)
|
|
398
404
|
continue
|
|
@@ -404,7 +410,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
|
|
|
404
410
|
// means the folder and the pool disagree, and writing the file without it
|
|
405
411
|
// would quietly unpublish that record on the next push.
|
|
406
412
|
warnings.push(
|
|
407
|
-
`records.yml: a folder leaf ("${node.
|
|
413
|
+
`records.yml: a folder leaf ("${node.name ?? '?'}") references a record that ` +
|
|
408
414
|
`was not written locally — the file was left unchanged rather than dropping it.`
|
|
409
415
|
)
|
|
410
416
|
return null
|
|
@@ -523,7 +529,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
523
529
|
//
|
|
524
530
|
// The folder ENTITY still carries no `$uuid` we persist: the backend owns the
|
|
525
531
|
// site's folder, keyed by the site-content uuid.
|
|
526
|
-
const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid })
|
|
532
|
+
const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
|
|
527
533
|
warnings.push(...records.warnings)
|
|
528
534
|
|
|
529
535
|
// Flush localized record-field translations to locales/records/{locale}.json,
|