@uniweb/build 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/content/index.js +6 -6
- package/src/dev-backend.js +31 -31
- package/src/i18n/freeform.js +44 -24
- package/src/i18n/index.js +22 -22
- package/src/i18n/{collections.js → records.js} +114 -51
- package/src/i18n/sync.js +9 -8
- package/src/site/build-site-data.js +9 -12
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +25 -40
- package/src/site/data-fetcher.js +23 -10
- package/src/site/entity-pool.js +211 -0
- package/src/site/fetch-shapes.js +71 -0
- package/src/site/foundation-ref.js +1 -1
- package/src/site/index.js +4 -4
- package/src/site/plugin.js +58 -63
- package/src/site/queries-config.js +324 -0
- package/src/site/{collection-processor.js → query-processor.js} +180 -95
- package/src/site/records-config.js +299 -0
- package/src/site/schemaless-data.js +2 -2
- package/src/utils/numeric-prefix.js +63 -0
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/data-schema.js +2 -2
- package/src/uwx/entity-source.js +122 -0
- package/src/uwx/folder.js +85 -77
- package/src/uwx/index.js +33 -12
- package/src/uwx/locale-sync.js +2 -2
- package/src/uwx/project-writer.js +36 -10
- package/src/uwx/queries-config.js +11 -0
- package/src/uwx/records-project.js +535 -0
- package/src/uwx/{collections.js → records.js} +152 -69
- package/src/uwx/site-diff.js +24 -1
- package/src/uwx/site-project.js +9 -6
- package/src/uwx/site.js +179 -23
- package/src/uwx/sync-package.js +37 -16
- package/src/validate-data.js +17 -19
- package/src/site/collections-config.js +0 -260
- package/src/uwx/collection-source.js +0 -180
- package/src/uwx/collections-config.js +0 -9
- package/src/uwx/collections-project.js +0 -335
- /package/src/search/{collections.js → records-index.js} +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// A site's QUERY declarations — the ONE resolution, for every lane.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THIS LIVED IN `uwx/` AND THE SITE BUILD COULD NOT SEE IT, so the site build
|
|
4
|
+
// used `site.yml::collections` directly and the two disagreed. Measured before the
|
|
5
|
+
// move: a collection declared only in `collections/collections.yml` resolved here
|
|
6
|
+
// and was INVISIBLE to the build — no `dist/data/<name>.json`, so `data: <name>`
|
|
7
|
+
// delivered nothing while sync pushed it fine. Declared in both files, the build
|
|
8
|
+
// took `site.yml`'s values and sync took `collections.yml`'s, so an author writing
|
|
9
|
+
// `sort: date desc` here got `date asc` baked into the static file.
|
|
10
|
+
//
|
|
11
|
+
// The broken case was the one the public docs recommend. See
|
|
12
|
+
// `kb/framework/plans/one-collections-config.md`.
|
|
13
|
+
//
|
|
14
|
+
// ⭐ A QUERY IS SECOND-ORDER SITE CONTENT — it describes how to REACH content, and
|
|
15
|
+
// is evaluated rather than rendered. `queries.yml` is a BARE MAP of name → query at
|
|
16
|
+
// the site root; `site.yml::queries` is the same vocabulary for a site that would
|
|
17
|
+
// rather keep one file. Precedence (per-query, per-key): queries.yml > site.yml.
|
|
18
|
+
//
|
|
19
|
+
// ⛔ THE THREE JOBS `collections/<name>/` USED TO FUSE ARE NOW THREE THINGS.
|
|
20
|
+
// `entities/{schema}/` is the pool, `records.yml` is the folder (what makes an
|
|
21
|
+
// entity a record), and a query asks the folder for a set. This file resolves the
|
|
22
|
+
// LAST of those only. Model: `kb/framework/plans/records-model.md`.
|
|
23
|
+
//
|
|
24
|
+
// ⚠️ `collections.yml` and `site.yml::collections` are GONE, with no alias and no
|
|
25
|
+
// deprecation path — the model's §5 ruling, and there is nothing outside this
|
|
26
|
+
// workspace on the old paths. Do not reintroduce dual support.
|
|
27
|
+
//
|
|
28
|
+
// When a query declares no schema, the query-name convention fills it
|
|
29
|
+
// (`articles` → `@/articles`). Absent the file entirely, a site simply has no
|
|
30
|
+
// queries — and therefore delivers no collection data.
|
|
31
|
+
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
34
|
+
import { briefFields, flatRecordFields } from '@uniweb/schemas/conform'
|
|
35
|
+
import { detectFoundationType } from './foundation-ref.js'
|
|
36
|
+
import { readFile } from 'node:fs/promises'
|
|
37
|
+
import yaml from 'js-yaml'
|
|
38
|
+
|
|
39
|
+
// Read its own YAML rather than importing the site build's helper. That import
|
|
40
|
+
// pointed the wrong way — a config resolver reaching into the collector that
|
|
41
|
+
// consumes it — and became a cycle the moment the site build started calling
|
|
42
|
+
// this. Four lines beats a dependency between two modules that should not know
|
|
43
|
+
// about each other.
|
|
44
|
+
async function readYamlFile(filePath) {
|
|
45
|
+
if (!existsSync(filePath)) return {}
|
|
46
|
+
try {
|
|
47
|
+
return yaml.load(await readFile(filePath, 'utf8')) || {}
|
|
48
|
+
} catch {
|
|
49
|
+
return {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const QUERIES_YML_RELPATH = 'queries.yml'
|
|
54
|
+
|
|
55
|
+
// The default data-schema ref for a query that declares none: the query's
|
|
56
|
+
// OWN NAME, unchanged. `@/` is the self scope — the local foundation's `schemas/` —
|
|
57
|
+
// so this stays backend-independent.
|
|
58
|
+
//
|
|
59
|
+
// ⛔ IT USED TO APPLY AN ENGLISH SINGULAR RULE to every language. Measured:
|
|
60
|
+
// `news` → `@/new`, `series` → `@/sery`, `analyses` → `@/analys`. Right for regular
|
|
61
|
+
// English plurals, right-by-accident elsewhere (`noticias` → `noticia`), and inert
|
|
62
|
+
// for languages with no plural marker.
|
|
63
|
+
//
|
|
64
|
+
// ⚠️ The failure was SILENT, which is why the rule was removed rather than improved.
|
|
65
|
+
// A default that does not resolve is not an error — the collection soft-skips to
|
|
66
|
+
// delivery-only — so a `news` collection simply never synced as entities, and
|
|
67
|
+
// nothing said the cause was a guess about English morphology.
|
|
68
|
+
//
|
|
69
|
+
// A more complete rule would move that boundary, not remove it: every irregular
|
|
70
|
+
// list belongs to one language, and a site names its collections in its own.
|
|
71
|
+
// Identity has no boundary to get wrong, and an author wanting a different schema
|
|
72
|
+
// name writes `schema:`, which is one line and says what it means.
|
|
73
|
+
//
|
|
74
|
+
// Exported so the inverse (projection) can drop a `schema:` that merely restates
|
|
75
|
+
// this default, keeping a projected queries.yml as terse as the author left it.
|
|
76
|
+
export function defaultSchema(name) {
|
|
77
|
+
return `@/${name}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Normalize one query entry (string shorthand or object) to the internal decl shape.
|
|
81
|
+
//
|
|
82
|
+
// ⛔ NO `path:` IS FILLED IN. A query names a `schema:` and the folder supplies
|
|
83
|
+
// the records; `path:`/`source:` mean something only for a REMOTE source, whose
|
|
84
|
+
// address nothing local can derive.
|
|
85
|
+
function normalizeQueryDecl(name, decl) {
|
|
86
|
+
// ⭐ THE STRING SHORTHAND NAMES THE SCHEMA, because that is what a file-based
|
|
87
|
+
// query actually needs — `entities/{schema}/` supplies the records, so there is
|
|
88
|
+
// no directory left to name. (It named a PATH while the pool was
|
|
89
|
+
// `collections/<name>/` and the same directory answered both questions.)
|
|
90
|
+
if (typeof decl === 'string') return { name, schema: decl }
|
|
91
|
+
const d = decl && typeof decl === 'object' ? decl : {}
|
|
92
|
+
return { name, ...d }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ⛔ `defaultPoolPath` WAS DELETED, NOT KEPT AS A DEFAULT. A file-based query
|
|
96
|
+
// carries no `path:` at all now: the pool location is derivable from `schema:` on
|
|
97
|
+
// both sides (`site/entity-pool.js::poolDirsForSchema`), so shipping it would be
|
|
98
|
+
// emitting a derivation as though the author had written it — the exact defect
|
|
99
|
+
// `deferred:` taught. `source:` survives on the wire for REMOTE (`url:`) queries,
|
|
100
|
+
// where the address is genuinely external and nothing can derive it.
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a site's merged QUERY declarations.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} siteRoot - directory containing site.yml + queries.yml
|
|
106
|
+
* @param {object} [opts]
|
|
107
|
+
* @param {object} [opts.siteYml] - an already-read site.yml (avoids a re-read)
|
|
108
|
+
* @returns {Promise<{
|
|
109
|
+
* folderSync: boolean, // vestigial — see below; always true
|
|
110
|
+
* hasQueriesYml: boolean,
|
|
111
|
+
* declarations: object, // { name: decl } — merged, schema-defaulted
|
|
112
|
+
* folders: Array|null, // the folder's virtual org, or null
|
|
113
|
+
* }>}
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
116
|
+
const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
117
|
+
const ymlPath = join(siteRoot, QUERIES_YML_RELPATH)
|
|
118
|
+
const hasQueriesYml = existsSync(ymlPath)
|
|
119
|
+
// ⛔ A BARE MAP — `queries.yml` has no root key. The file IS the map, the way
|
|
120
|
+
// `records.yml` IS the list. A `queries:` key inside it would be a name a query
|
|
121
|
+
// could then collide with.
|
|
122
|
+
const queriesYml = hasQueriesYml ? await readYamlFile(ymlPath) : {}
|
|
123
|
+
|
|
124
|
+
const declarations = {}
|
|
125
|
+
|
|
126
|
+
// site.yml::queries first (lower precedence) — the same vocabulary, for a site
|
|
127
|
+
// that would rather not carry a second file.
|
|
128
|
+
const siteQueries = siteYml?.queries
|
|
129
|
+
if (siteQueries && typeof siteQueries === 'object' && !Array.isArray(siteQueries)) {
|
|
130
|
+
for (const [name, decl] of Object.entries(siteQueries)) {
|
|
131
|
+
declarations[name] = normalizeQueryDecl(name, decl)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// queries.yml overlay (higher precedence; per-key merge).
|
|
136
|
+
if (queriesYml && typeof queriesYml === 'object' && !Array.isArray(queriesYml)) {
|
|
137
|
+
for (const [name, decl] of Object.entries(queriesYml)) {
|
|
138
|
+
const incoming = normalizeQueryDecl(name, decl)
|
|
139
|
+
declarations[name] = { ...(declarations[name] || {}), ...incoming, name }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Schema default (query-name convention) + `model:`→`schema:` synonym.
|
|
144
|
+
// `schemaExplicit` records whether the author asked for this schema: an explicit
|
|
145
|
+
// schema that fails to resolve is a hard error; a convention-defaulted one that
|
|
146
|
+
// fails to resolve soft-skips (so a delivery-only query never breaks sync).
|
|
147
|
+
for (const decl of Object.values(declarations)) {
|
|
148
|
+
if (decl.schema) {
|
|
149
|
+
decl.schemaExplicit = true
|
|
150
|
+
} else if (decl.model) {
|
|
151
|
+
decl.schema = decl.model // migration synonym
|
|
152
|
+
decl.schemaExplicit = true
|
|
153
|
+
} else if (!decl.url) {
|
|
154
|
+
decl.schema = defaultSchema(decl.name) // query-name convention
|
|
155
|
+
decl.schemaExplicit = false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ⛔ A `path:` ON A FILE-BASED QUERY DOES NOTHING, so say so. It resolved the
|
|
160
|
+
// pool while the pool was `collections/<name>/`; now `schema:` does, and a key
|
|
161
|
+
// that is quietly inert is how an author spends an afternoon on a query that
|
|
162
|
+
// was reading a different set of files all along. It stays meaningful for a
|
|
163
|
+
// REMOTE source, whose address nothing local can derive.
|
|
164
|
+
for (const decl of Object.values(declarations)) {
|
|
165
|
+
if (decl.path && !decl.url) {
|
|
166
|
+
console.warn(
|
|
167
|
+
`[uniweb] query "${decl.name}": \`path: ${decl.path}\` is ignored. A query names a ` +
|
|
168
|
+
`\`schema:\` and \`entities/{schema}/\` supplies its records — there is no directory ` +
|
|
169
|
+
`for it to name. Move the files under the schema folder instead.`
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await deriveDeferredFromSchemas(siteRoot, siteYml, declarations)
|
|
175
|
+
|
|
176
|
+
// ⛔ BOTH OF THESE ARE VESTIGIAL FOR ONE STEP, and deliberately not deleted here.
|
|
177
|
+
//
|
|
178
|
+
// `folderSync` was `collections.yml::sync`. The model DELETES that mechanism
|
|
179
|
+
// rather than porting it: "do not sync" becomes "reference nothing in
|
|
180
|
+
// `records.yml`" — the actual round trip. Its one reader is
|
|
181
|
+
// `uwx/records.js`, and it goes when `records.yml` supplies the real
|
|
182
|
+
// control. Until then it must stay TRUE, or nothing syncs at all.
|
|
183
|
+
//
|
|
184
|
+
// `folders` was `collections.yml::folders`, the virtual org. Its one reader is
|
|
185
|
+
// `uwx/sync-package.js`, and `records.yml` replaces it. Null meanwhile is the
|
|
186
|
+
// long-standing default (`folder.js::defaultContents` — one branch per query).
|
|
187
|
+
//
|
|
188
|
+
// ⚠️ Leaving them as literals rather than ripping out their readers keeps this
|
|
189
|
+
// step revertible on its own, which is the whole reason the work is ordered.
|
|
190
|
+
return {
|
|
191
|
+
folderSync: true,
|
|
192
|
+
hasQueriesYml,
|
|
193
|
+
declarations,
|
|
194
|
+
folders: null,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The declarations as `config.queries` should carry them.
|
|
200
|
+
*
|
|
201
|
+
* `schemaExplicit` records whether the AUTHOR asked for a schema or the
|
|
202
|
+
* subfolder-name convention supplied one. That decides how a failed resolution
|
|
203
|
+
* behaves during sync — hard error vs soft skip — and is nobody's business
|
|
204
|
+
* downstream. It is stripped here rather than at each consumer, so the payload
|
|
205
|
+
* has one shape and no consumer has to know the field existed.
|
|
206
|
+
*
|
|
207
|
+
* Returns undefined for a site with no queries, so `config.queries`
|
|
208
|
+
* stays absent rather than becoming an empty object — an empty object reads as
|
|
209
|
+
* "declared, and empty" to anything checking for presence.
|
|
210
|
+
*/
|
|
211
|
+
export function toConfigQueries(declarations) {
|
|
212
|
+
const names = Object.keys(declarations || {})
|
|
213
|
+
if (names.length === 0) return undefined
|
|
214
|
+
const out = {}
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
const { schemaExplicit, ...rest } = declarations[name]
|
|
217
|
+
out[name] = rest
|
|
218
|
+
}
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Fill in `deferred:` from each collection's own data schema.
|
|
225
|
+
*
|
|
226
|
+
* ⭐ A schema's **brief** section already states what a record's summary is — the
|
|
227
|
+
* card, the row, the thing a list shows. Everything else is wanted only when one
|
|
228
|
+
* record is the focus. That is exactly what `deferred:` says, so an author with a
|
|
229
|
+
* schema should not have to say it twice, in a second vocabulary, with nothing
|
|
230
|
+
* checking the two against each other.
|
|
231
|
+
*
|
|
232
|
+
* ⇒ `deferred` = the schema's flat-record fields MINUS its brief fields.
|
|
233
|
+
*
|
|
234
|
+
* Derived from the SCHEMA, never from a record. That is what keeps the
|
|
235
|
+
* build-derived keys safe without a reserved list: `slug`, `route`, `path`,
|
|
236
|
+
* `excerpt`, `image` and `lastModified` are not schema fields, so they are never
|
|
237
|
+
* in the difference and never stripped. `content` is not exempt — it is
|
|
238
|
+
* schema-governed, and usually the heavy field the split exists for.
|
|
239
|
+
*
|
|
240
|
+
* ⛔ Silent on every path that cannot answer, because none of them is an error:
|
|
241
|
+
*
|
|
242
|
+
* - an author-declared `deferred:` wins outright — this never overrides one;
|
|
243
|
+
* - no local foundation (a linked or cataloged one), or it is unbuilt → nothing
|
|
244
|
+
* to read, and a site must still build;
|
|
245
|
+
* - the schema is not in the foundation's built map → the same soft-skip the
|
|
246
|
+
* sync lane already applies. `dist/meta/schema.json` carries the schemas
|
|
247
|
+
* COMPONENTS reference, so a collection whose schema no component binds is
|
|
248
|
+
* simply not there;
|
|
249
|
+
* - the schema states no brief (`briefFields` → null, e.g. a root list) → there
|
|
250
|
+
* is no lean shape to honour, so records stay whole.
|
|
251
|
+
*
|
|
252
|
+
* The last two are why this reads the built artifact rather than resolving
|
|
253
|
+
* schemas itself: it is the same input the sync lane uses, so both lanes agree
|
|
254
|
+
* about which schemas exist.
|
|
255
|
+
*/
|
|
256
|
+
async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
|
|
257
|
+
const pending = Object.values(declarations).filter(
|
|
258
|
+
(d) => d.schema && !Array.isArray(d.deferred)
|
|
259
|
+
)
|
|
260
|
+
if (pending.length === 0) return
|
|
261
|
+
|
|
262
|
+
const dataSchemas = loadFoundationDataSchemas(siteRoot, siteYml)
|
|
263
|
+
if (!dataSchemas) return
|
|
264
|
+
|
|
265
|
+
for (const decl of pending) {
|
|
266
|
+
const heavy = deferredFromSchema(dataSchemas[decl.schema])
|
|
267
|
+
if (heavy) decl.deferred = heavy
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The `deferred:` a schema implies — every record field its **brief** does not name.
|
|
273
|
+
*
|
|
274
|
+
* ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
|
|
275
|
+
* above uses it to FILL an unstated `deferred:`; `uwx/collections-project.js` uses it to
|
|
276
|
+
* RECOGNIZE a derived value on the way back in, so a pull does not write a derivation
|
|
277
|
+
* into the author's file as though they had typed it.
|
|
278
|
+
*
|
|
279
|
+
* ⚠️ A second copy would drift, and the drift would be invisible: the deriver and the
|
|
280
|
+
* inverter would simply stop agreeing about which values are "the derived one", and the
|
|
281
|
+
* pull would start persisting values it was written to drop.
|
|
282
|
+
*
|
|
283
|
+
* @param {object|undefined} schema a data schema, or undefined when it does not resolve
|
|
284
|
+
* @returns {string[]|null} the implied deferred list, or null when the schema states no
|
|
285
|
+
* brief (a root list, say) or implies nothing heavy — in both cases there is no
|
|
286
|
+
* derivation to recognize
|
|
287
|
+
*/
|
|
288
|
+
export function deferredFromSchema(schema) {
|
|
289
|
+
if (!schema) return null
|
|
290
|
+
const brief = briefFields(schema)
|
|
291
|
+
if (!brief) return null
|
|
292
|
+
const all = Object.keys(flatRecordFields(schema) || {})
|
|
293
|
+
const heavy = all.filter((f) => !brief.has(f))
|
|
294
|
+
return heavy.length ? heavy : null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The data schemas a site's foundation declares, or null when unresolvable. */
|
|
298
|
+
export function foundationDataSchemas(siteRoot, siteYml) {
|
|
299
|
+
return loadFoundationDataSchemas(siteRoot, siteYml)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The foundation's built data-schema map, or null when there is nothing to read. */
|
|
303
|
+
function loadFoundationDataSchemas(siteRoot, siteYml) {
|
|
304
|
+
if (!siteYml?.foundation) return null
|
|
305
|
+
let info
|
|
306
|
+
try {
|
|
307
|
+
info = detectFoundationType(siteYml.foundation, siteRoot)
|
|
308
|
+
} catch {
|
|
309
|
+
return null // a declaration this resolver refuses is not this function's error
|
|
310
|
+
}
|
|
311
|
+
if (info?.type !== 'local' || !info.path) return null
|
|
312
|
+
const schemaPath = join(info.path, 'dist', 'meta', 'schema.json')
|
|
313
|
+
if (!existsSync(schemaPath)) return null
|
|
314
|
+
try {
|
|
315
|
+
return JSON.parse(readFileSync(schemaPath, 'utf8'))?.dataSchemas || null
|
|
316
|
+
} catch {
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Path to the queries.yml file (whether or not it exists yet). */
|
|
322
|
+
export function queriesYmlPath(siteRoot) {
|
|
323
|
+
return join(siteRoot, QUERIES_YML_RELPATH)
|
|
324
|
+
}
|