@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.
@@ -2,7 +2,7 @@
2
2
  // referenced BY NAME, on the entity-content SYNC lane.
3
3
  //
4
4
  // Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
5
- // `$id` (the slug — the producer-local handle), `$model` (the Model by name), and
5
+ // `$id` (the producer-local handle), `$model` (the Model by name), and
6
6
  // each SINGLE section keyed by its name — the brief plus any sibling singles, not
7
7
  // the brief alone. The backend MINTS `$uuid` on first sync and
8
8
  // returns it in the finalized response; the verb back-fills it into the source
@@ -91,8 +91,9 @@ function stripSigils(value) {
91
91
  // ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
92
92
  // encoding rather than the reference made the folder's hash unreproducible.
93
93
  //
94
- // `refLeaf` (uwx/folder.js) emits `$ref: "<collection>/<slug>"` while the record
95
- // is brand-new and `entry: { model, entity: <uuid> }` once it has been minted.
94
+ // `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
95
+ // `<dirs>/<slug>` — while the record is brand-new, and `entry: { model, entity:
96
+ // <uuid> }` once it has been minted.
96
97
  // Both denote the same record. A push hashes the folder BEFORE submitting, then
97
98
  // back-fills the minted `$uuid` into every record's source file — so the very
98
99
  // next emit builds the OTHER encoding, and the hash the push just banked can
@@ -103,14 +104,14 @@ function stripSigils(value) {
103
104
  //
104
105
  // ⭐ Neither encoding is content. What the folder SAYS is "this branch contains
105
106
  // this record, here, in this order" — and that is already hashed: a leaf carries
106
- // `path_segment` (the record's slug) inside a branch carrying the collection's.
107
+ // `name` (the record's handle) inside a branch carrying the collection's.
107
108
  // A `folders:` branch's entries are COLLECTION names, so every leaf under one
108
109
  // comes from a single collection, where a slug is unique. Position plus segment
109
110
  // therefore identify the record on their own; `$ref` adds a payload-local handle
110
111
  // and `entry` adds identity, and both are exactly what `$uuid` is stripped for.
111
112
  //
112
113
  // ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
113
- // is: point a leaf at a different record and its `path_segment` moves with it.
114
+ // is: point a leaf at a different record and its `name` moves with it.
114
115
  const isFolderRefLeaf = value.kind === 'ref'
115
116
  const out = {}
116
117
  for (const [k, v] of Object.entries(value)) {
@@ -181,7 +182,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
181
182
  * already carries `$uuid` (back-filled from a prior sync) round-trips it.
182
183
  *
183
184
  * @param {object} params
184
- * @param {string} params.queryName - the site.yml collection name
185
+ * @param {string} params.queryName - the query's name in site.yml
185
186
  * @param {object[]} params.records - [{ slug, ...fields }]
186
187
  * @param {object} params.declaration - the `@uniweb/data-schema` declaration
187
188
  * (from toDataSchemaDeclaration): `{ name, brief, sections }`
@@ -272,9 +273,22 @@ export function recordsToEntities({
272
273
  warnings.push(`${queryName}: a record without a slug was skipped`)
273
274
  continue
274
275
  }
275
- // `$id` is the payload-local handle = the record's path under collections/
276
- // (`<collection>/<slug>`), globally unique within one sync so the @uniweb/folder
277
- // entity can point a leaf at it via `$ref`. An explicit frontmatter `$id` wins.
276
+ // `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
277
+ // the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
278
+ // frontmatter `$id` wins.
279
+ //
280
+ // ⚠️ The authoritative value is the record's POOL POSITION — `<dirs>/<slug>` —
281
+ // and it is set upstream, at the pool walk; see the ⭐ comment there, which is
282
+ // where the reasoning lives. `<query>/<slug>` below is only the fallback for a
283
+ // record that did not arrive through the pool, and it is explicitly NOT the
284
+ // shape identity is meant to take: two queries over one Model would mint two
285
+ // identities for one file.
286
+ //
287
+ // The qualification is a CONSTRAINT, not a style: the sync response is keyed per
288
+ // (`$model`, `$id`), so a bare slug would collide whenever two queries over the
289
+ // same Model reuse one (see the duplicate check below). ⇒ Do not describe this
290
+ // value as "the slug" — the folder leaf's `name` is the bare segment, and
291
+ // conflating the two has already misdirected a naming decision.
278
292
  const id = record.$id || `${queryName}/${slug}`
279
293
  const uuid = record.$uuid || null
280
294
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
@@ -631,7 +645,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
631
645
  // statically (the "data ball") instead, so the caller can route them there.
632
646
  const schemaless = []
633
647
  // The sync response is keyed per ($model, $id), so the pair must be unique
634
- // within one submission (two collections on the same Model could otherwise
648
+ // within one submission (two queries over the same Model could otherwise
635
649
  // reuse a slug).
636
650
  const seen = new Set()
637
651
  for (const { name, decl } of mapped) {
@@ -159,6 +159,11 @@ function buildInfo(self, org, digest, runtime) {
159
159
  // be satisfied. Same lift as `digest`: stated by the producer, opaque to the
160
160
  // backend, acted on by whoever resolves a whole site.
161
161
  if (runtime) info.runtime = runtime
162
+ // The host services this foundation is BUILT AGAINST, from package.json's
163
+ // `uniweb.supports`. `Array.isArray` and not truthiness: `[]` is an explicit
164
+ // "none" and must survive as one, while an ABSENT key means UNKNOWN — the
165
+ // same three-state rule `runtime` states above, for the same reason.
166
+ if (Array.isArray(self.supports)) info.supports = self.supports
162
167
  return info
163
168
  }
164
169
 
@@ -166,7 +171,11 @@ function buildInfo(self, org, digest, runtime) {
166
171
  // as one opaque object the backend never reads into (custodian).
167
172
  function buildSchemaBlob(schema) {
168
173
  const { dataSchemas: _ds, ...rest } = schema
169
- const { name: _n, version: _v, description: _d, role: _r, ...selfConfig } = rest._self || {}
174
+ // Every key hoisted into `info` is stripped here, so the wire carries each
175
+ // fact ONCE. Two copies of one fact is a drift liability, and the copy inside
176
+ // an opaque blob is the one nobody would think to update.
177
+ const { name: _n, version: _v, description: _d, role: _r, supports: _s, ...selfConfig } =
178
+ rest._self || {}
170
179
  return { ...rest, _self: selfConfig }
171
180
  }
172
181
 
@@ -513,7 +513,12 @@ function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
513
513
  // is already a plain string.
514
514
  export function pageDirName(record, sourceLocale) {
515
515
  const slug = unwrapLocalized(record.slug, sourceLocale)
516
- return record.is_dynamic ? `[${record.param_name || slug}]` : slug
516
+ if (!record.is_dynamic) return slug
517
+ // The multi-segment folder rides the wire as slug `...path` with
518
+ // `param_name: slug` (the handle it delivers by); it comes back as the one
519
+ // fixed spelling, never as `[slug]`.
520
+ if (slug === '...path') return '[...path]'
521
+ return `[${record.param_name || slug}]`
517
522
  }
518
523
 
519
524
  // Pass 1 — write + relocate every page dir, its page.yml/folder.yml, and its
package/src/uwx/site.js CHANGED
@@ -334,6 +334,12 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
334
334
  }
335
335
 
336
336
  const DYNAMIC_RE = /^\[(.+)\]$/
337
+ // The multi-segment route folder, one fixed spelling (`content-collector.js`).
338
+ // On the wire its page `slug` is the marker itself (`...path`) and its
339
+ // `param_name` is `slug`: the record is delivered by its handle, the last
340
+ // segment, exactly as under `[slug]`. ⚠️ What a consumer's projector emits as
341
+ // the page ROUTE for it is that consumer's; framework expects `/…/:path*`.
342
+ const CATCH_ALL_MARKER = '...path'
337
343
 
338
344
  // ===========================================================================
339
345
  // NESTED ($-document) lane — Phase 0 de-flatten (bidirectional-sync §8).
@@ -545,7 +551,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
545
551
  slug,
546
552
  mode,
547
553
  isDynamic: !!dyn,
548
- paramName: dyn ? dyn[1] : undefined,
554
+ paramName: dyn ? (dyn[1] === CATCH_ALL_MARKER ? 'slug' : dyn[1]) : undefined,
549
555
  isRoot,
550
556
  siteIndex,
551
557
  sourceLocale,
@@ -684,7 +690,7 @@ export function isSiteRelativeExtensionUrl(decl) {
684
690
  * declaration could occupy, nothing is ever recorded for one, and every push re-sent
685
691
  * this whole section uuid-less. The backend refuses that (an all-blank section over
686
692
  * stored items would delete every stored row), which is why `push` worked once and
687
- * every push after it was refused. Measured 2026-08-29; collab framework-backend-812b.
693
+ * every push after it was refused. Measured 2026-08-29; collab frameworkbackend.
688
694
  *
689
695
  * ⭐ `name` is the right key and not merely the available one — the backend enforces
690
696
  * `unique_field(name, scope: section)` on this section, and it is the join key its
@@ -724,8 +730,6 @@ export function isSiteRelativeExtensionUrl(decl) {
724
730
  * `site.yml collections.<name>.label`; no such field has ever existed, and they have
725
731
  * corrected it.
726
732
  *
727
- * ⇒ Full record, including what is established vs merely claimed:
728
- * `kb/framework/build/collections-decl-open-questions.md`.
729
733
  *
730
734
  * @param {object} declarations resolved collection declarations, keyed by name
731
735
  * @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
@@ -276,6 +276,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
276
276
  // are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
277
277
  const folder = buildFolderEntity({
278
278
  recordEntities: col.entities,
279
+ ...(sourceLocale ? { sourceLocale } : {}),
279
280
  // ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
280
281
  // collection — which made the folder a shadow of a directory layout rather
281
282
  // than something the author states.
@@ -1,247 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Dev backend for testing Uniweb sites with `supports: [where, limit, sort]`.
4
- *
5
- * Reads a directory of YAML recordsByQuery (each subfolder is a collection,
6
- * each .yml file inside is a record) and exposes them via HTTP. Evaluates
7
- * where-objects on the server side using @uniweb/core's matchWhere — the
8
- * exact same evaluator the runtime uses as a fallback. This lets you
9
- * develop a site against a "real" backend without standing up a database.
10
- *
11
- * Wire format matches the framework default fetcher's pushdown conventions
12
- * (see framework/runtime/src/default-fetcher.js):
13
- *
14
- * GET /api/{collection} — all records
15
- * GET /api/{collection}?_where=<JSON> — filtered by where-object
16
- * GET /api/{collection}?_limit=N — first N records
17
- * GET /api/{collection}?_sort=field:dir — sorted
18
- * POST /api/{collection} body: { where, ... } — operators in body
19
- * GET /api/{collection}/{slug} — single record
20
- *
21
- * Usage:
22
- * node scripts/framework/dev-backend.js --recordsByQuery <path> [--port N]
23
- *
24
- * Example (academic-metrics):
25
- * node scripts/framework/dev-backend.js \
26
- * --recordsByQuery framework/templates/academic-metrics/site/recordsByQuery \
27
- * --port 8080
28
- *
29
- * Then in the site's site.yml:
30
- * fetcher:
31
- * baseUrl: http://localhost:8080
32
- * supports: [where, limit, sort]
33
- *
34
- * And rewrite collection refs to URLs, e.g.:
35
- * fetch: { url: /api/members, schema: members }
36
- */
37
-
38
- import { createServer } from 'node:http'
39
- import { readFile, readdir, stat } from 'node:fs/promises'
40
- import { existsSync } from 'node:fs'
41
- import { resolve, join, basename, extname } from 'node:path'
42
- import { parseArgs } from 'node:util'
43
- import yaml from 'js-yaml'
44
- import { matchWhere } from '@uniweb/core'
45
-
46
- const { values } = parseArgs({
47
- options: {
48
- entities: { type: 'string', short: 'e' },
49
- port: { type: 'string', short: 'p', default: '8080' },
50
- },
51
- })
52
-
53
- if (!values.entities) {
54
- console.error('Usage: dev-backend.js --entities <path> [--port N]')
55
- process.exit(1)
56
- }
57
-
58
- const ENTITIES_ROOT = resolve(values.entities)
59
- const PORT = Number(values.port)
60
-
61
- if (!existsSync(ENTITIES_ROOT)) {
62
- console.error(`Entities directory not found: ${ENTITIES_ROOT}`)
63
- process.exit(1)
64
- }
65
-
66
- // ─── Load recordsByQuery from disk ─────────────────────────────────────────────
67
-
68
- async function loadRecords(dir) {
69
- const files = await readdir(dir)
70
- const items = []
71
- for (const file of files) {
72
- const ext = extname(file).toLowerCase()
73
- if (!['.yml', '.yaml', '.json'].includes(ext)) continue
74
- const filepath = join(dir, file)
75
- const content = await readFile(filepath, 'utf8')
76
- let data
77
- try {
78
- data = ext === '.json' ? JSON.parse(content) : yaml.load(content)
79
- } catch (err) {
80
- console.warn(`[dev-backend] Failed to parse ${filepath}: ${err.message}`)
81
- continue
82
- }
83
- if (data == null) continue
84
- const slug = basename(file, ext)
85
- if (Array.isArray(data)) {
86
- // Array-form file: each element is a record.
87
- for (const record of data) {
88
- if (record && typeof record === 'object') items.push(record)
89
- }
90
- } else if (typeof data === 'object') {
91
- items.push({ slug, ...data })
92
- }
93
- }
94
- return items
95
- }
96
-
97
- async function loadAllRecords() {
98
- const entries = await readdir(ENTITIES_ROOT)
99
- const recordsByQuery = {}
100
- for (const name of entries) {
101
- const fullPath = join(ENTITIES_ROOT, name)
102
- const s = await stat(fullPath)
103
- if (!s.isDirectory()) continue
104
- recordsByQuery[name] = await loadRecords(fullPath)
105
- console.log(`[dev-backend] Loaded ${recordsByQuery[name].length} items from "${name}"`)
106
- }
107
- return recordsByQuery
108
- }
109
-
110
- // ─── Operator handling (mirrors default-fetcher pushdown wire format) ───────
111
-
112
- function applyOperators(items, operators) {
113
- let result = items
114
- if (operators.where) {
115
- result = matchWhere(operators.where, result)
116
- }
117
- if (operators.sort) {
118
- result = applySort(result, operators.sort)
119
- }
120
- if (typeof operators.limit === 'number' && operators.limit > 0) {
121
- result = result.slice(0, operators.limit)
122
- }
123
- return result
124
- }
125
-
126
- function applySort(items, sortExpr) {
127
- const sorts = String(sortExpr).split(',').map((s) => {
128
- const [field, dir = 'asc'] = s.trim().split(/\s+/)
129
- return { field, desc: dir.toLowerCase() === 'desc' }
130
- })
131
- return [...items].sort((a, b) => {
132
- for (const { field, desc } of sorts) {
133
- const av = a?.[field] ?? ''
134
- const bv = b?.[field] ?? ''
135
- if (av < bv) return desc ? 1 : -1
136
- if (av > bv) return desc ? -1 : 1
137
- }
138
- return 0
139
- })
140
- }
141
-
142
- function parseOperatorsFromQuery(searchParams) {
143
- const out = {}
144
- if (searchParams.has('_where')) {
145
- try {
146
- out.where = JSON.parse(searchParams.get('_where'))
147
- } catch (err) {
148
- throw new Error(`Invalid _where JSON: ${err.message}`)
149
- }
150
- }
151
- if (searchParams.has('_limit')) {
152
- out.limit = Number(searchParams.get('_limit'))
153
- }
154
- if (searchParams.has('_sort')) {
155
- out.sort = searchParams.get('_sort')
156
- }
157
- return out
158
- }
159
-
160
- async function readJsonBody(req) {
161
- return new Promise((resolve, reject) => {
162
- let body = ''
163
- req.on('data', (chunk) => { body += chunk })
164
- req.on('end', () => {
165
- if (!body) return resolve({})
166
- try { resolve(JSON.parse(body)) }
167
- catch (err) { reject(new Error(`Invalid JSON body: ${err.message}`)) }
168
- })
169
- req.on('error', reject)
170
- })
171
- }
172
-
173
- // ─── HTTP server ────────────────────────────────────────────────────────────
174
-
175
- function send(res, status, body) {
176
- res.writeHead(status, {
177
- 'Content-Type': 'application/json',
178
- 'Access-Control-Allow-Origin': '*',
179
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
180
- 'Access-Control-Allow-Headers': 'Content-Type',
181
- })
182
- res.end(typeof body === 'string' ? body : JSON.stringify(body))
183
- }
184
-
185
- async function handleRequest(req, res, recordsByQuery) {
186
- if (req.method === 'OPTIONS') return send(res, 204, '')
187
-
188
- const url = new URL(req.url, `http://${req.headers.host}`)
189
- const match = url.pathname.match(/^\/api\/([^/]+)(?:\/([^/]+))?$/)
190
- if (!match) return send(res, 404, { error: 'Not found' })
191
-
192
- const [, queryName, slug] = match
193
- const items = recordsByQuery[queryName]
194
- if (!items) return send(res, 404, { error: `Unknown query: ${queryName}` })
195
-
196
- // Single record by slug.
197
- if (slug) {
198
- const item = items.find((r) => r?.slug === slug)
199
- if (!item) return send(res, 404, { error: `No record with slug "${slug}"` })
200
- return send(res, 200, item)
201
- }
202
-
203
- // Collection — apply operators from query string (GET) or body (POST).
204
- let operators
205
- try {
206
- operators = req.method === 'POST'
207
- ? await readJsonBody(req)
208
- : parseOperatorsFromQuery(url.searchParams)
209
- } catch (err) {
210
- return send(res, 400, { error: err.message })
211
- }
212
-
213
- let result
214
- try {
215
- result = applyOperators(items, operators)
216
- } catch (err) {
217
- return send(res, 400, { error: `Operator evaluation failed: ${err.message}` })
218
- }
219
- return send(res, 200, result)
220
- }
221
-
222
- // ─── Boot ───────────────────────────────────────────────────────────────────
223
-
224
- const recordsByQuery = await loadAllRecords()
225
- const knownQueries = Object.keys(recordsByQuery)
226
- if (knownQueries.length === 0) {
227
- console.warn('[dev-backend] No recordsByQuery found.')
228
- }
229
-
230
- const server = createServer((req, res) => {
231
- handleRequest(req, res, recordsByQuery).catch((err) => {
232
- console.error('[dev-backend] Request handler threw:', err)
233
- send(res, 500, { error: 'Internal server error' })
234
- })
235
- })
236
-
237
- server.listen(PORT, () => {
238
- console.log(`[dev-backend] Listening on http://localhost:${PORT}`)
239
- console.log(`[dev-backend] Queries: ${knownQueries.join(', ') || '(none)'}`)
240
- console.log('[dev-backend] Endpoints:')
241
- for (const name of knownQueries) {
242
- console.log(` GET /api/${name} — all records`)
243
- console.log(` GET /api/${name}?_where=<JSON> — filtered`)
244
- console.log(` GET /api/${name}/{slug} — single record`)
245
- console.log(` POST /api/${name} body: { where } — operators in body`)
246
- }
247
- })