@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.
- package/package.json +8 -8
- package/src/site/build-site-data.js +10 -3
- package/src/site/collection-processor.js +89 -6
- package/src/site/collections-config.js +260 -0
- package/src/site/config.js +6 -174
- package/src/site/content-collector.js +12 -0
- package/src/site/data-fetcher.js +47 -0
- package/src/site/foundation-ref.js +207 -0
- package/src/uwx/collection-source.js +57 -1
- package/src/uwx/collections-config.js +9 -125
- package/src/uwx/collections.js +105 -21
- package/src/uwx/site.js +18 -3
- package/src/uwx/sync-package.js +4 -0
- package/src/vite-foundation-plugin.js +19 -4
package/src/site/data-fetcher.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// and
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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'
|
package/src/uwx/collections.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|