@uniweb/build 0.26.1 → 0.27.1

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.
@@ -239,6 +239,50 @@ export function applyPostProcessing(data, config) {
239
239
  * parseFetchConfig({ collection: 'articles', limit: 3, sort: 'date desc' })
240
240
  * // Returns: { path: '/data/articles.json', schema: 'articles', limit: 3, sort: 'date desc', ... }
241
241
  */
242
+ // ─── Unrecognized-key reporting ───────────────────────────────────────
243
+ //
244
+ // `parseFetchConfig` reads an explicit allowlist and builds a new object, so
245
+ // anything the author wrote that is not on that list is DROPPED — silently,
246
+ // with no warning and no trace in the output. A typo (`wehre:`), a field from
247
+ // another config block, or a capability the author believed existed all look
248
+ // identical to having written nothing.
249
+ //
250
+ // ⛔ That silence is the defect, not the dropping. We cannot act on a key we do
251
+ // not understand, but we can refuse to pretend it was never there. Reported
252
+ // once per key name per process so a 200-record build does not print 200 lines.
253
+ const RECOGNIZED_FETCH_KEYS = {
254
+ refine: new Set(['refine', 'inherit', 'detail', 'limit', 'sort', 'where', 'filter']),
255
+ collection: new Set([
256
+ 'collection', 'schema', 'prerender', 'merge', 'transform',
257
+ 'where', 'limit', 'sort', 'detailPage', 'filter',
258
+ ]),
259
+ source: new Set([
260
+ 'path', 'url', 'schema', 'prerender', 'merge', 'transform', 'detail',
261
+ 'detailPage', 'where', 'limit', 'sort', 'filter',
262
+ ]),
263
+ }
264
+
265
+ const warnedUnknownFetchKeys = new Set()
266
+
267
+ function warnUnknownFetchKeys(fetch, shape) {
268
+ const recognized = RECOGNIZED_FETCH_KEYS[shape]
269
+ for (const key of Object.keys(fetch)) {
270
+ if (recognized.has(key)) continue
271
+ const seenKey = `${shape}:${key}`
272
+ if (warnedUnknownFetchKeys.has(seenKey)) continue
273
+ warnedUnknownFetchKeys.add(seenKey)
274
+ console.warn(
275
+ `[uniweb] fetch: unrecognized key "${key}" was ignored. ` +
276
+ `Keys recognized on this declaration: ${[...recognized].sort().join(', ')}.`
277
+ )
278
+ }
279
+ }
280
+
281
+ /** Test seam — reset the once-per-key memo so suites do not leak into each other. */
282
+ export function _resetUnknownFetchKeyWarnings() {
283
+ warnedUnknownFetchKeys.clear()
284
+ }
285
+
242
286
  export function parseFetchConfig(fetch) {
243
287
  if (!fetch) return null
244
288
 
@@ -269,6 +313,7 @@ export function parseFetchConfig(fetch) {
269
313
  // block are currently accepted by the parser but not honored at runtime.
270
314
  // Preserved as-is in this rename commit; revisit separately if needed.
271
315
  if (fetch.refine === true || fetch.inherit === true) {
316
+ warnUnknownFetchKeys(fetch, 'refine')
272
317
  if (fetch.inherit === true && fetch.refine !== true) {
273
318
  console.warn(
274
319
  "[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
@@ -288,6 +333,7 @@ export function parseFetchConfig(fetch) {
288
333
 
289
334
  // Collection reference: { collection: 'articles', limit: 3 }
290
335
  if (fetch.collection) {
336
+ warnUnknownFetchKeys(fetch, 'collection')
291
337
  if (fetch.filter !== undefined) warnFilterDeprecated()
292
338
  return {
293
339
  path: collectionDataUrl(fetch.collection),
@@ -308,6 +354,7 @@ export function parseFetchConfig(fetch) {
308
354
  }
309
355
  }
310
356
 
357
+ warnUnknownFetchKeys(fetch, 'source')
311
358
  const {
312
359
  path,
313
360
  url,
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Where a site's foundation lives — the ONE resolver for a `site.yml::foundation`
3
+ * declaration.
4
+ *
5
+ * ## Why this is a leaf and not part of the Vite config
6
+ *
7
+ * It began in `./config.js`, which imports a Vite plugin. That made it unreachable
8
+ * from any lane that must not pull Vite — and the sync lane, needing exactly this
9
+ * answer, grew its own weaker copy instead (`../uwx/collections.js`), which read
10
+ * `package.json` `dependencies.foundation`: a key no current template produces, so
11
+ * it returned null for every scaffolded site. A third copy in the CLI describes
12
+ * itself as mirroring "a subset of" this one.
13
+ *
14
+ * Three implementations of one question, none of which agreed, and the cause was
15
+ * a pure function sitting behind a bundler import. So it moved here, with no
16
+ * dependency beyond `node:fs` and `node:path`, the same reason `@uniweb/core`'s
17
+ * `route-match` and `data-paths` are leaves.
18
+ *
19
+ * ## What it answers, and what it refuses
20
+ *
21
+ * A site names its foundation; it does not name a path. This turns that name into
22
+ * a location, or says it is a URL, or refuses — loudly — when the declaration is a
23
+ * shape we do not support.
24
+ *
25
+ * ⛔ **A site's `package.json` is CLI scaffolding, not part of the site.** The
26
+ * `file:` dep is consulted, but keyed by the DECLARED name from `site.yml` — the
27
+ * declaration leads, and the manifest is one of the places an answer may be found.
28
+ * Reversing that (starting from the manifest) is what the weaker copies did.
29
+ */
30
+
31
+ import { existsSync, readFileSync } from 'node:fs'
32
+ import { resolve, join } from 'node:path'
33
+
34
+ /**
35
+ * Detect foundation type from the foundation config value
36
+ *
37
+ * Foundations are runtime federated modules, never npm packages — there is
38
+ * no fall-through to `node_modules`. A foundation reference resolves to one
39
+ * of two types:
40
+ *
41
+ * - `'local'` — workspace-local source (sibling directory, file: dep, or
42
+ * `../../foundations/<name>/`). The build inlines or runtime-links it
43
+ * depending on the operating mode.
44
+ * - `'url'` — loaded by URL at runtime. Two URL shapes:
45
+ * - `@org/name@ver` → catalog ref (resolves against the registry CDN)
46
+ * - `https://...` → arbitrary URL
47
+ *
48
+ * Versionless registry refs (`@org/name`) are rejected with a specific error —
49
+ * they were a silent fall-through before. Versionless names that don't match
50
+ * any local resolution path are also rejected, with guidance toward the right
51
+ * shape.
52
+ *
53
+ * @param {string|Object} foundation - Foundation config from site.yml
54
+ * @param {string} siteRoot - Path to site directory
55
+ * @returns {{ type: 'local'|'url', name?: string, url?: string, cssUrl?: string, path?: string }}
56
+ * @throws {Error} when the declaration shape is invalid (versionless registry
57
+ * ref, unknown name with no local match, etc.)
58
+ */
59
+ export function detectFoundationType(foundation, siteRoot) {
60
+ // Object form with explicit URL
61
+ if (foundation && typeof foundation === 'object') {
62
+ if (foundation.url) {
63
+ return {
64
+ type: 'url',
65
+ url: foundation.url,
66
+ cssUrl: foundation.css || foundation.cssUrl || null
67
+ }
68
+ }
69
+ // Object form with name
70
+ foundation = foundation.name || 'foundation'
71
+ }
72
+
73
+ // String form
74
+ const name = foundation || 'foundation'
75
+
76
+ // Check if it's a URL
77
+ if (name.startsWith('http://') || name.startsWith('https://')) {
78
+ // Try to infer CSS URL from JS URL
79
+ const cssUrl = name.replace(/\.js$/, '.css').replace(/foundation\.js/, 'assets/style.css')
80
+ return {
81
+ type: 'url',
82
+ url: name,
83
+ cssUrl
84
+ }
85
+ }
86
+
87
+ // Catalog registry ref: `@org/name@version`.
88
+ //
89
+ // A build does NOT turn this into a URL. Where a foundation is served is the
90
+ // host's to say — a serve location is READ (from backend discovery, or from an
91
+ // upload plan's `serve_base`), never reconstructed. `@uniweb/cli`'s
92
+ // DISCOVERY_DEFAULTS carries no serve-root default for exactly this reason.
93
+ // A build is offline and backend-optional by design, so it has nothing to ask.
94
+ //
95
+ // A ref names a foundation in the Uniweb platform's catalog, and `uniweb
96
+ // publish` — the verb reserved for that target — has the platform resolve it,
97
+ // running no vite build. That is one hosting target among many. The others
98
+ // reach a host through `uniweb deploy --host=<adapter>` or `uniweb export`,
99
+ // and both need a concrete URL, which the site declares: the runtime accepts
100
+ // any URL, from any host.
101
+ //
102
+ // Until 2026-08-04 this returned `{base}/foundations/{ns}/{name}/{ver}/foundation.js`
103
+ // against a hardcoded host, overridable only through an env var that did not
104
+ // match the documented backend selection — so `--backend`, `uniweb login
105
+ // --backend` and the documented env var all left it pinned — and the artifact
106
+ // names were the pre-`entry.js` ones the build stopped emitting.
107
+ const orgScopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
108
+ if (orgScopedMatch) {
109
+ throw new Error(
110
+ [
111
+ `Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
112
+ `Where a foundation is served is the host's to declare, so the build does not guess it.`,
113
+ ``,
114
+ ` • Deploying to another host (\`uniweb deploy --host=<adapter>\`), or taking`,
115
+ ` the build anywhere (\`uniweb export\`)? Declare the served URL in site.yml —`,
116
+ ` the runtime accepts any URL, from any host:`,
117
+ ``,
118
+ ` foundation: https://<host>/<path>/entry.js`,
119
+ ``,
120
+ ` or the object form when the stylesheet sits elsewhere:`,
121
+ ``,
122
+ ` foundation: { url: 'https://…/entry.js', cssUrl: 'https://…/assets/style.css' }`,
123
+ ``,
124
+ ` • Iterating locally?`,
125
+ ` Reference the workspace foundation by package name.`,
126
+ ``,
127
+ ` • Targeting the Uniweb platform?`,
128
+ ` \`uniweb publish\` has the platform resolve the ref — no build-time URL needed.`
129
+ ].join('\n')
130
+ )
131
+ }
132
+
133
+ // Versionless scoped names (`@org/name`) are valid as *handles* — they
134
+ // resolve through the local checks below (file: dep,
135
+ // workspace sibling) when the developer is iterating locally on a
136
+ // foundation that will eventually be published as `@org/name@ver`.
137
+ // Tianyu's uniweb.io site uses this shape:
138
+ // site.yml: foundation: '@uniweb/io'
139
+ // package.json: "@uniweb/io": "file:../../foundations/io"
140
+ // The file: dep check below picks it up. If no local resolution exists
141
+ // either, the function throws below with a "missing version" hint.
142
+
143
+ // Check if it's a local workspace sibling (directory name matches package name)
144
+ const localPath = resolve(siteRoot, '..', name)
145
+ if (existsSync(localPath)) {
146
+ return {
147
+ type: 'local',
148
+ name,
149
+ path: localPath
150
+ }
151
+ }
152
+
153
+ // Check if it's a file: dependency (co-located projects where dir name ≠ package name)
154
+ // e.g. "marketing-foundation": "file:../foundation" in marketing/site/package.json
155
+ try {
156
+ const pkg = JSON.parse(readFileSync(resolve(siteRoot, 'package.json'), 'utf8'))
157
+ const dep = pkg.dependencies?.[name]
158
+ if (dep && dep.startsWith('file:')) {
159
+ const filePath = resolve(siteRoot, dep.slice(5))
160
+ if (existsSync(filePath)) {
161
+ return {
162
+ type: 'local',
163
+ name,
164
+ path: filePath
165
+ }
166
+ }
167
+ }
168
+ } catch {}
169
+
170
+ // Check in foundations/ directory (for multi-site projects)
171
+ const foundationsPath = resolve(siteRoot, '..', '..', 'foundations', name)
172
+ if (existsSync(foundationsPath)) {
173
+ return {
174
+ type: 'local',
175
+ name,
176
+ path: foundationsPath
177
+ }
178
+ }
179
+
180
+ // Versionless scoped name that didn't resolve locally — likely a typo
181
+ // or a missing file: dep. Give a specific hint distinguishing the two
182
+ // common causes (forgot @version vs. forgot to wire the file: dep).
183
+ if (/^@[a-z0-9_-]+\//.test(name)) {
184
+ throw new Error(
185
+ `site.yml foundation: '${name}' did not resolve to a local source and no version was specified.\n` +
186
+ `If this is a workspace-local foundation, add it to the site's package.json:\n` +
187
+ ` "dependencies": { "${name}": "file:../path/to/foundation" }\n` +
188
+ `If this is a published catalog ref, include the version: '${name}@<version>' (e.g. '${name}@0.1.2').`
189
+ )
190
+ }
191
+
192
+ // Foundations are not npm packages. If we get here, the declaration
193
+ // didn't match a workspace sibling, a `file:` dep, a foundations/ entry,
194
+ // a registry ref, or a URL — none of the supported shapes. Fail with
195
+ // guidance rather than fall through to a node_modules lookup that will
196
+ // produce a confusing error later in the build.
197
+ throw new Error(
198
+ `site.yml foundation: '${name}' did not resolve.\n` +
199
+ `Foundations must be one of:\n` +
200
+ ` - a workspace-local sibling (a directory next to the site, named '${name}')\n` +
201
+ ` - a 'file:' dep in the site's package.json\n` +
202
+ ` - a directory in '../../foundations/${name}'\n` +
203
+ ` - a versioned registry ref: '@org/${name}@<version>'\n` +
204
+ ` - a full URL: 'https://...'\n` +
205
+ `Foundations are runtime federated modules, not npm packages — there is no fall-through to node_modules.`
206
+ )
207
+ }
@@ -111,10 +111,15 @@ export async function readCollectionRecords(collectionDir) {
111
111
  if (!existsSync(collectionDir)) {
112
112
  throw new Error(`uwx/collection-source: collection folder not found: ${collectionDir}`)
113
113
  }
114
- const files = (await readdir(collectionDir))
114
+ const entries = await readdir(collectionDir, { withFileTypes: true })
115
+ const files = entries
116
+ .filter((e) => e.isFile())
117
+ .map((e) => e.name)
115
118
  .filter((f) => !f.startsWith('_') && SOURCE_EXTENSIONS.has(extname(f).toLowerCase()))
116
119
  .sort() // stable order — the wire's package digest depends on it
117
120
 
121
+ await reportNestedRecords(collectionDir, entries)
122
+
118
123
  const records = []
119
124
  for (const file of files) {
120
125
  const recs = await readOneFile(resolve(collectionDir, file))
@@ -122,3 +127,54 @@ export async function readCollectionRecords(collectionDir) {
122
127
  }
123
128
  return records
124
129
  }
130
+
131
+ /**
132
+ * Report source records nested below a collection's top level.
133
+ *
134
+ * ⛔ THIS LANE IS FLAT AND THAT IS A CONTRACT, NOT AN OVERSIGHT. The delivery
135
+ * build reads a collection recursively — an author may organise records into
136
+ * branches, and `path` + the `under` predicate address them
137
+ * (`build/src/site/collection-processor.js`). Sync cannot follow yet: the
138
+ * folder shape agreed with the entity store is one level, records as direct
139
+ * leaves of a collection's branch, and going deeper needs that renegotiated
140
+ * with an explicit order axis rather than assumed.
141
+ *
142
+ * ⚠️ The two lanes therefore disagree, and the failure mode is the dangerous
143
+ * shape: a nested record BUILDS and RENDERS locally, then is simply absent from
144
+ * everything the sync produced — no error, no empty result, just a smaller set
145
+ * than the author is looking at. Losing records silently is the one outcome
146
+ * that must not happen, so this says so. It does not throw: a static site with
147
+ * nested collections is completely valid and must keep working, and an author
148
+ * who never syncs should not be blocked by a lane they do not use.
149
+ */
150
+ async function reportNestedRecords(collectionDir, entries) {
151
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
152
+ if (dirs.length === 0) return
153
+
154
+ const nested = []
155
+ for (const dir of dirs) {
156
+ nested.push(...(await findSourceFiles(resolve(collectionDir, dir.name), dir.name)))
157
+ }
158
+ if (nested.length === 0) return
159
+
160
+ const shown = nested.slice(0, 5).join(', ')
161
+ const more = nested.length > 5 ? `, and ${nested.length - 5} more` : ''
162
+ console.warn(
163
+ `[uwx/collection-source] ${nested.length} record(s) below the top level of ` +
164
+ `"${basename(collectionDir)}" are NOT synced: ${shown}${more}. ` +
165
+ `Collection sync is one level deep; these build and render locally but are ` +
166
+ `absent from the synced set. Move them to the collection's top level to sync them.`
167
+ )
168
+ }
169
+
170
+ /** Every source file at or below `dir`, as paths relative to the collection root. */
171
+ async function findSourceFiles(dir, rel) {
172
+ const out = []
173
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
174
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
175
+ const relPath = `${rel}/${entry.name}`
176
+ if (entry.isDirectory()) out.push(...(await findSourceFiles(resolve(dir, entry.name), relPath)))
177
+ else if (SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) out.push(relPath)
178
+ }
179
+ return out
180
+ }
@@ -1,125 +1,9 @@
1
- // Resolve a site's collection configuration from the (optional, local-first)
2
- // `collections/collections.yml`, layered over the legacy `site.yml::collections`
3
- // and the zero-config subfolder-name convention.
4
- //
5
- // `collections.yml` is the co-located home for FILE-BASED collection declarations
6
- // (it sits with the data it describes). It is useful with NO backend at all — it
7
- // maps each subfolder to a data schema, declares query/display config, and can lay
8
- // out a VIRTUAL folder organization decoupled from the on-disk layout. (Sync holds no
9
- // folder uuid here — the backend owns the site's `@uniweb/folder`, keyed by the
10
- // site-content uuid.)
11
- //
12
- // Precedence (per-collection, per-key): collections.yml > site.yml::collections.
13
- // `site.yml::collections` stays valid for remote `url:` sources and back-compat.
14
- // When neither declares a schema, the subfolder-name convention fills it
15
- // (`articles` → `@/article`). Absent the file entirely, behavior is unchanged.
16
-
17
- import { join } from 'node:path'
18
- import { existsSync } from 'node:fs'
19
- import { readYamlFile } from '../site/content-collector.js'
20
-
21
- export const COLLECTIONS_YML_RELPATH = 'collections/collections.yml'
22
-
23
- // Naive English singularization — enough for the schema-name default (an explicit
24
- // `schema:` always overrides). `categories` → `category`, `boxes` → `box`,
25
- // `articles` → `article`, `team` → `team` (unchanged).
26
- function singularize(name) {
27
- if (/[^aeiou]ies$/i.test(name)) return name.slice(0, -3) + 'y'
28
- if (/(ses|xes|zes|ches|shes)$/i.test(name)) return name.slice(0, -2)
29
- if (/[^s]s$/i.test(name)) return name.slice(0, -1)
30
- return name
31
- }
32
-
33
- // Default data-schema ref for a collection with no explicit `schema:` — the
34
- // self-scope (`@/`) singular of its name. `@/` resolves to the local foundation's
35
- // `schemas/` (named-data-schemas), so this stays backend-independent. Exported so
36
- // the inverse (projection) can drop a `schema:` that merely restates this default,
37
- // keeping a projected collections.yml as terse as the author would have left it.
38
- export function defaultSchema(name) {
39
- return `@/${singularize(name)}`
40
- }
41
-
42
- // Normalize one site.yml::collections entry (string shorthand or object) to the
43
- // internal decl shape. Paths here are already site-root-relative (legacy contract).
44
- function normalizeSiteDecl(name, decl) {
45
- if (typeof decl === 'string') return { name, path: decl }
46
- const d = decl && typeof decl === 'object' ? decl : {}
47
- return { name, ...d }
48
- }
49
-
50
- // Normalize one collections.yml::collections entry. Its `path:` is relative to the
51
- // collections/ directory (default = the collection name); we lift it to a
52
- // site-root-relative path so downstream readers resolve it uniformly.
53
- function normalizeYmlDecl(name, decl) {
54
- const d = decl && typeof decl === 'object' ? decl : {}
55
- const rel = typeof d.path === 'string' ? d.path : name
56
- return { name, ...d, path: `collections/${rel}` }
57
- }
58
-
59
- /**
60
- * Resolve the merged collection configuration for a site.
61
- *
62
- * @param {string} siteRoot - directory containing site.yml + collections/
63
- * @param {object} [opts]
64
- * @param {object} [opts.siteYml] - an already-read site.yml (avoids a re-read)
65
- * @returns {Promise<{
66
- * folderSync: boolean, // collections.yml `sync` (whole-folder opt-out)
67
- * hasCollectionsYml: boolean,
68
- * declarations: object, // { name: decl } — merged, schema-defaulted
69
- * folders: Array|null, // collections.yml `folders` (virtual org) or null
70
- * }>}
71
- */
72
- export async function resolveCollectionsConfig(siteRoot, opts = {}) {
73
- const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
74
- const ymlPath = join(siteRoot, COLLECTIONS_YML_RELPATH)
75
- const hasCollectionsYml = existsSync(ymlPath)
76
- const colYml = hasCollectionsYml ? await readYamlFile(ymlPath) : {}
77
-
78
- const declarations = {}
79
-
80
- // Legacy site.yml::collections first (lower precedence).
81
- const siteCols = siteYml?.collections
82
- if (siteCols && typeof siteCols === 'object' && !Array.isArray(siteCols)) {
83
- for (const [name, decl] of Object.entries(siteCols)) {
84
- declarations[name] = normalizeSiteDecl(name, decl)
85
- }
86
- }
87
-
88
- // collections.yml::collections overlay (higher precedence; per-key merge).
89
- const ymlCols = colYml?.collections
90
- if (ymlCols && typeof ymlCols === 'object' && !Array.isArray(ymlCols)) {
91
- for (const [name, decl] of Object.entries(ymlCols)) {
92
- const incoming = normalizeYmlDecl(name, decl)
93
- declarations[name] = { ...(declarations[name] || {}), ...incoming, name }
94
- }
95
- }
96
-
97
- // Schema default (subfolder-name convention) + `model:`→`schema:` synonym.
98
- // `schemaExplicit` records whether the author asked for this schema: an explicit
99
- // schema that fails to resolve is a hard error; a convention-defaulted one that
100
- // fails to resolve soft-skips (so a delivery-only collection never breaks sync).
101
- for (const decl of Object.values(declarations)) {
102
- if (decl.schema) {
103
- decl.schemaExplicit = true
104
- } else if (decl.model) {
105
- decl.schema = decl.model // migration synonym
106
- decl.schemaExplicit = true
107
- } else if (decl.path || !decl.url) {
108
- decl.schema = defaultSchema(decl.name) // subfolder-name convention
109
- decl.schemaExplicit = false
110
- }
111
- }
112
-
113
- const folderSync = colYml?.sync !== false
114
- return {
115
- folderSync,
116
- hasCollectionsYml,
117
- declarations,
118
- folders: Array.isArray(colYml?.folders) ? colYml.folders : null,
119
- }
120
- }
121
-
122
- /** Path to the collections.yml file (whether or not it exists yet). */
123
- export function collectionsYmlPath(siteRoot) {
124
- return join(siteRoot, COLLECTIONS_YML_RELPATH)
125
- }
1
+ // Moved to `../site/collections-config.js` — a site's collection declarations are
2
+ // a site-build question first, and keeping the resolver here meant the build could
3
+ // not reach it and answered differently. Re-exported so no caller moved.
4
+ export {
5
+ resolveCollectionsConfig,
6
+ collectionsYmlPath,
7
+ defaultSchema,
8
+ COLLECTIONS_YML_RELPATH,
9
+ } from '../site/collections-config.js'
@@ -3,7 +3,8 @@
3
3
  //
4
4
  // Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
5
5
  // `$id` (the slug — the producer-local handle), `$model` (the Model by name), and
6
- // the brief section keyed by its name. The backend MINTS `$uuid` on first sync and
6
+ // each SINGLE section keyed by its name the brief plus any sibling singles, not
7
+ // the brief alone. The backend MINTS `$uuid` on first sync and
7
8
  // returns it in the finalized response; the verb back-fills it into the source
8
9
  // file. A record that already carries `$uuid` (a prior back-fill) round-trips it
9
10
  // for restore-in-place. No id sidecar — identity is the file-embedded `$uuid` plus
@@ -15,11 +16,32 @@
15
16
  // via `toDataSchemaDeclaration`, the same path `uniweb register` uses), so it
16
17
  // stays offline.
17
18
  //
18
- // v1 scope: a FLAT record -> the Model's brief `single` section. Deferred:
19
- // multi / nested / non-brief sections (`$children` self-nesting), entity_ref /
20
- // item_ref / file fields, and remote (non-local) foundations.
19
+ // Scope: this mapper implements the FLAT-RECORD shape one source file whose
20
+ // frontmatter keys are field names so it walks the Model's `single` sections
21
+ // and skips `multi` ones.
22
+ //
23
+ // ⛔ That is a property of THIS MAPPER, not of the schema system, and saying
24
+ // otherwise has already misled twice. `multi` is first-class: the author writes
25
+ // `many: true`, which lowers to IR `kind: 'multi'` and to wire `multiple: true`.
26
+ // A Model whose ONLY section is `many` is a supported shape in its own right — a
27
+ // root list, content authored as a bare array (`@uniweb/schemas` `rootListSection`;
28
+ // `@std/nav` and `@std/form` are exactly this). Such a Model has no flat-record
29
+ // surface at all, so it is not that its records "cannot be expressed" — it is
30
+ // that they are not this shape, and this mapper only knows this shape.
31
+ //
32
+ // Nested sections (a `type: section` field) and entity_ref / item_ref / file
33
+ // fields have no branch in `encodeFieldValue`; unverified either way.
34
+ //
35
+ // ⚠️ Two claims that used to sit here were stale and were removed rather than
36
+ // re-worded, because both named real capabilities as missing: NON-BRIEF single
37
+ // sections are handled (see `recordSections` below — the filter is `multiple !==
38
+ // true`, not `brief === true`), and REMOTE foundations are handled through the
39
+ // injected `opts.resolveModel`. A scope note that under-claims is worse than none:
40
+ // it sends a reader to build what is already there.
21
41
 
22
42
  import { readFileSync, existsSync } from 'node:fs'
43
+ import yaml from 'js-yaml'
44
+ import { detectFoundationType } from '../site/foundation-ref.js'
23
45
  import { join, resolve } from 'node:path'
24
46
 
25
47
  import { resolveCollectionsConfig } from './collections-config.js'
@@ -161,10 +183,27 @@ export function collectionRecordsToEntities({
161
183
  if (!briefName) {
162
184
  throw new Error(`uwx/collections: Model ${declaration.name} has no brief section`)
163
185
  }
164
- // The single sections a flat record can populate (the brief + sibling singles),
165
- // and a global field→section map across them — for distributing frontmatter and
166
- // flagging unknown keys. Field names are unique across a Model's sections (the
167
- // declaration's own convention); a collision keeps the first occurrence.
186
+ // The single sections one record can populate (the brief + sibling singles).
187
+ //
188
+ // `fieldByKey` IS NOT A FIELD→SECTION ROUTING TABLE, and must not be used as
189
+ // one. It answers exactly one question "is this frontmatter key declared
190
+ // anywhere on this Model?" — for the unknown-key warning below. The assignment
191
+ // loop does not consult it: it walks each section and reads `record[key]` afresh.
192
+ //
193
+ // ⚠️ SO A FIELD NAME DECLARED IN TWO SECTIONS FANS OUT. The same frontmatter
194
+ // value is written into BOTH sections, each encoded per its own field's type — so
195
+ // a name shared by, say, a `string` and a `json` field yields one plausible value
196
+ // and one malformed one, silently. And flat frontmatter has no way to give the
197
+ // two fields different values in the first place: the representation is lossy
198
+ // exactly where names collide.
199
+ //
200
+ // ⛔ Nothing prevents this. A previous version of this comment asserted that
201
+ // "field names are unique across a Model's sections (the declaration's own
202
+ // convention)" — that is FALSE, no such convention holds, and nothing validates
203
+ // it: `resolve-data-schema.js` throws in 14 places and never checks this, and the
204
+ // only `unique_field` in the schema translator is a section-scoped constraint on
205
+ // an open map's KEY VALUE, which is unrelated. The invariant was asserted, relied
206
+ // on, and never provided.
168
207
  const recordSections = sectionEntries.filter(([, s]) => s && s.multiple !== true)
169
208
  const fieldByKey = new Map()
170
209
  for (const [, sec] of recordSections) {
@@ -327,21 +366,31 @@ function syncableCollections(declarations) {
327
366
  // Resolve the foundation dir from an explicit opt, else the site's `file:`
328
367
  // foundation dep. A local foundation supplies locally-defined Model declarations
329
368
  // offline; non-local Models are fetched via an injected resolver (see below).
369
+ // Where this site's foundation lives, via the ONE resolver for that question
370
+ // (`../site/foundation-ref.js` — a leaf precisely so this lane can import it).
371
+ //
372
+ // This used to be a private copy that read `package.json` `dependencies.foundation`
373
+ // — a key no current template produces, since a site's foundation dep is keyed by
374
+ // the foundation's package name (`"src": "file:../src"`). It returned null for
375
+ // every scaffolded site, so the local-foundation path silently never ran: with a
376
+ // `resolveModel` wired the caller fell back to the backend, and without one every
377
+ // collection soft-skipped to delivery-only.
378
+ //
379
+ // The site declares its foundation in `site.yml`; the resolver turns that
380
+ // declaration into a location. A declaration it refuses (a versionless registry
381
+ // ref, an unknown name) is not this function's error to raise — the caller decides
382
+ // whether a local foundation was required — so a throw becomes "no local
383
+ // foundation" here and the caller's `required` flag still owns the message.
330
384
  function resolveFoundationDir(siteRoot, opts) {
331
385
  if (opts.foundationDir) return resolve(opts.foundationDir)
332
- const pkgPath = join(siteRoot, 'package.json')
333
- if (existsSync(pkgPath)) {
334
- try {
335
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
336
- const dep = pkg.dependencies?.foundation || pkg.devDependencies?.foundation
337
- if (typeof dep === 'string' && dep.startsWith('file:')) {
338
- return resolve(siteRoot, dep.slice('file:'.length))
339
- }
340
- } catch {
341
- // fall through to the not-found error below
342
- }
386
+ try {
387
+ const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8')) || {}
388
+ if (!siteYml.foundation) return null
389
+ const info = detectFoundationType(siteYml.foundation, siteRoot)
390
+ return info?.type === 'local' && info.path ? info.path : null
391
+ } catch {
392
+ return null
343
393
  }
344
- return null
345
394
  }
346
395
 
347
396
  // Load the local foundation's built schema.json (the source of locally-defined
@@ -477,6 +526,30 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
477
526
  const entities = []
478
527
  const index = []
479
528
  const warnings = []
529
+
530
+ // ⛔ `@/x` IS A FOUNDATION-RELATIVE ALIAS AND MUST BE RESOLVED BEFORE IT SHIPS.
531
+ //
532
+ // `register` resolves it (`uwx/registry-package.js` builds `scoped` from the
533
+ // publish scope and applies it to BOTH the declaration's name and its refs), so
534
+ // a foundation's `@/member` is stored as `@org/member`. This path did NOT, and
535
+ // carried the alias verbatim into `$model` and `models_required.name_at_export`.
536
+ //
537
+ // ⚠️ The backend resolves Models BY NAME and never mints, so an unresolved alias
538
+ // is refused at restore with a message about a missing Model — which reads as a
539
+ // registration problem rather than a producer one. Measured 2026-08-27 on a live
540
+ // manor: `register` had already stored `@proximify/member` from the same alias,
541
+ // and the push then named `@/member`. One CLI, two paths, one resolver.
542
+ //
543
+ // ⭐ Resolving BEFORE `declarationFor` is what keeps this to one line of behaviour:
544
+ // `resolveDeclaration` already matches a fully-qualified name against the
545
+ // foundation's `@/`-keyed `dataSchemas`, so a resolved name looks up correctly and
546
+ // `declaration.name` — the value that becomes `$model` — is the resolved one.
547
+ const selfScopeOrg =
548
+ typeof opts.org === 'string' ? opts.org.replace(/^@/, '').replace(/\/.*$/, '') : ''
549
+ const resolveSelfScope = (ref) =>
550
+ typeof ref === 'string' && ref.startsWith('@/') && selfScopeOrg
551
+ ? `@${selfScopeOrg}/${ref.slice(2)}`
552
+ : ref
480
553
  // Collections that resolved no data schema (the convention-default soft-skip
481
554
  // below) — not synced as folder entities. The composite deploy delivers these
482
555
  // statically (the "data ball") instead, so the caller can route them there.
@@ -486,7 +559,18 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
486
559
  // reuse a slug).
487
560
  const seen = new Set()
488
561
  for (const { name, decl } of mapped) {
489
- const modelName = decl.schema || decl.model
562
+ const declaredModel = decl.schema || decl.model
563
+ const modelName = resolveSelfScope(declaredModel)
564
+ // Unresolvable `@/` — no org is known. Ship it rather than throwing (a `status`
565
+ // probe on a never-pushed site has no org and must still count), but say so:
566
+ // the backend's refusal names a missing Model and cannot name this cause.
567
+ if (modelName === declaredModel && typeof declaredModel === 'string' && declaredModel.startsWith('@/')) {
568
+ warnings.push(
569
+ `collection "${name}": \`${declaredModel}\` is foundation-relative and no org is known, ` +
570
+ `so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
571
+ `Pass \`--org @handle\`, or push once so the site records its org.`
572
+ )
573
+ }
490
574
  const declaration = await declarationFor(modelName)
491
575
  if (!declaration) {
492
576
  // A convention-defaulted schema (subfolder-name) that doesn't resolve is a