@uniweb/build 0.64.1 → 0.65.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 +9 -9
- package/src/i18n/records.js +25 -12
- package/src/import-map-plugin.js +13 -0
- package/src/prerender.js +9 -4
- package/src/resolve-data-schema.js +4 -2
- package/src/runtime-schema.js +15 -18
- package/src/site/config.js +59 -5
- package/src/site/entity-pool.js +2 -2
- package/src/site/foundation-ref.js +21 -2
- package/src/site/index.js +2 -0
- package/src/site/preview-media.js +90 -0
- package/src/site/preview.js +84 -0
- package/src/site/queries-config.js +46 -83
- package/src/site/query-processor.js +251 -144
- package/src/site/record-file.js +35 -0
- package/src/site/records-config.js +21 -0
- package/src/site/registered-foundation.js +3 -2
- package/src/uwx/backfill.js +9 -7
- package/src/uwx/data-schema.js +8 -24
- package/src/uwx/entity-source.js +9 -20
- package/src/uwx/queries-config.js +0 -1
- package/src/uwx/record-layout.js +1 -1
- package/src/uwx/records-project.js +17 -94
- package/src/uwx/records.js +1 -26
- package/src/uwx/site-project.js +2 -5
- package/src/uwx/site.js +6 -6
- package/src/validate-data.js +3 -2
- package/src/vite-foundation-plugin.js +39 -193
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PREVIEW OF A SITE A BACKEND HOLDS — what `uniweb dev` read from that backend, handed to the one
|
|
3
|
+
* dev server it starts.
|
|
4
|
+
*
|
|
5
|
+
* A clone names its foundation by catalog ref (`@acme/fnd@1.2.0`) and keeps the media URLs the
|
|
6
|
+
* backend serves, so two things its dev server needs are the backend's to say. `uniweb dev` asks the
|
|
7
|
+
* backend the site is on, through the site, and passes what it learned in `UNIWEB_PREVIEW`, as JSON:
|
|
8
|
+
*
|
|
9
|
+
* { backend: 'https://…', the backend the site is on
|
|
10
|
+
* foundation: { ref, url, cssUrl } } where it serves the version site.yml names
|
|
11
|
+
*
|
|
12
|
+
* ⭐ It resolves the declared foundation; it never substitutes one. The answer names the ref it is
|
|
13
|
+
* for, and `detectFoundationType` honors it for that ref alone — which is what separates it from the
|
|
14
|
+
* `UNIWEB_FOUNDATION_REF` override `config.js` retired, a way for a site to be rendered by code its
|
|
15
|
+
* own `site.yml` did not name.
|
|
16
|
+
*
|
|
17
|
+
* ⭐ And a media URL the clone kept is the backend's own. A pull keeps any serve URL it cannot map
|
|
18
|
+
* back to an author's file (`uwx/asset-map.js::restoreAssetRefs`, *"the URL that works"*), and a
|
|
19
|
+
* root-relative one works on the origin that served the document holding it. So the dev server
|
|
20
|
+
* fetches from `backend` what the content names and the site does not have (`config.js`) — it
|
|
21
|
+
* resolves the URL it was given against the origin it came from, and composes no route.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ Nothing keeps it. A serve location is decided at publish and read from the response that
|
|
24
|
+
* carries it, so it lives as long as the process that asked: `site.yml` keeps the ref, and no push,
|
|
25
|
+
* pull or build sees a URL from here. A build handed one refuses it (`config.js`), so a preview's
|
|
26
|
+
* answer never reaches a built artifact.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ And it may not be known. Whether a backend tells the CLI where a version is served is the
|
|
29
|
+
* backend's to decide; `uniweb dev` says so and starts nothing when it is not told.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The variable `uniweb dev` sets for the dev server it starts. */
|
|
33
|
+
export const PREVIEW_ENV = 'UNIWEB_PREVIEW'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The variable's value.
|
|
37
|
+
*
|
|
38
|
+
* @param {{ backend: string, foundation: { ref: string, url: string, cssUrl?: string|null } }} preview
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function encodePreview({ backend, foundation }) {
|
|
42
|
+
const { ref, url, cssUrl = null } = foundation
|
|
43
|
+
return JSON.stringify({ backend, foundation: { ref, url, cssUrl: cssUrl || null } })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a dev server was handed, or null when it was handed nothing.
|
|
48
|
+
*
|
|
49
|
+
* @param {Record<string, string|undefined>} [env]
|
|
50
|
+
* @returns {{ backend: string, foundation: { ref: string, url: string, cssUrl: string|null } }|null}
|
|
51
|
+
* @throws {Error} when the variable is set and says nothing usable — a preview that cannot read its
|
|
52
|
+
* answer must not start as if it had none
|
|
53
|
+
*/
|
|
54
|
+
export function readPreview(env = process.env) {
|
|
55
|
+
const raw = env[PREVIEW_ENV]
|
|
56
|
+
if (!raw) return null
|
|
57
|
+
let given = null
|
|
58
|
+
try {
|
|
59
|
+
given = JSON.parse(raw)
|
|
60
|
+
} catch {
|
|
61
|
+
// reported below
|
|
62
|
+
}
|
|
63
|
+
const f = given?.foundation
|
|
64
|
+
if (!isHttpOrigin(given?.backend) || typeof f?.ref !== 'string' || typeof f.url !== 'string' || !f.url) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${PREVIEW_ENV} holds no preview (${JSON.stringify(raw).slice(0, 120)}). ` +
|
|
67
|
+
'It is set by `uniweb dev`; run that rather than setting it.'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
backend: new URL(given.backend).origin,
|
|
72
|
+
foundation: { ref: f.ref, url: f.url, cssUrl: typeof f.cssUrl === 'string' ? f.cssUrl : null }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ⛔ `new URL('localhost:8080')` parses — as the scheme `localhost:` — and its origin is the string
|
|
77
|
+
// "null". An origin is http(s), or it is nothing.
|
|
78
|
+
function isHttpOrigin(value) {
|
|
79
|
+
try {
|
|
80
|
+
return /^https?:$/.test(new URL(value).protocol)
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -35,6 +35,7 @@ import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
|
|
|
35
35
|
import { readRegisteredFoundation } from './registered-foundation.js'
|
|
36
36
|
import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
|
|
37
37
|
import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
|
|
38
|
+
import { buildSchema } from '../schema.js'
|
|
38
39
|
import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
|
|
39
40
|
import { readFile } from 'node:fs/promises'
|
|
40
41
|
import yaml from 'js-yaml'
|
|
@@ -174,8 +175,6 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
await deriveDeferredFromSchemas(siteRoot, siteYml, declarations)
|
|
178
|
-
|
|
179
178
|
// ⛔ `folderSync` AND `folders` WERE DELETED HERE, 2026-09-06. Both were
|
|
180
179
|
// `collections.yml` survivors kept "for one step" while `records.yml` took
|
|
181
180
|
// over, and both had become literals — `true` and `null` — that no longer
|
|
@@ -194,7 +193,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
194
193
|
}
|
|
195
194
|
|
|
196
195
|
/** What a query over the site's records declares, which an external query cannot. */
|
|
197
|
-
const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', '
|
|
196
|
+
const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'excerpt', 'path']
|
|
198
197
|
/** The keys of an external query's `record:` request. */
|
|
199
198
|
const RECORD_KEYS = ['url', 'method', 'body', 'transform']
|
|
200
199
|
|
|
@@ -207,7 +206,7 @@ const RECORD_KEYS = ['url', 'method', 'body', 'transform']
|
|
|
207
206
|
* `transform` (a dot-path to its records), `where` / `sort` / `limit` evaluated over
|
|
208
207
|
* them, and `record:` — `{ url, method, body, transform }` — for one record on a
|
|
209
208
|
* parametric page. What describes the site's records — `schema`, `scope`,
|
|
210
|
-
* `
|
|
209
|
+
* `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` and `detail:` are
|
|
211
210
|
* retired everywhere: their one real case is `record.url`. ⛔ And `limit:` is a whole
|
|
212
211
|
* number, 0 or more (`refuseLimit`).
|
|
213
212
|
*
|
|
@@ -235,6 +234,17 @@ export function refuseQueryDeclaration(decl) {
|
|
|
235
234
|
`on an external query — one with \`url:\`.`
|
|
236
235
|
)
|
|
237
236
|
}
|
|
237
|
+
// ⛔ `deferred:` IS RETIRED (2026-09-27 [Diego]: "the `deferred:` concept keeps getting in the way
|
|
238
|
+
// and doesn't match a hosted lane"). A list carries each record's brief, on every site, so a
|
|
239
|
+
// field a list should leave out belongs in a section of its own, outside the brief — where a
|
|
240
|
+
// host leaves it out too. `deferred:` only made a static site's lists differ from a hosted one's.
|
|
241
|
+
if (decl.deferred !== undefined) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`[uniweb] ${where}: \`deferred:\` is retired. A list carries each record's brief — the fields ` +
|
|
244
|
+
`of its schema's brief section — so a field a list should leave out belongs in a section of ` +
|
|
245
|
+
`its own, outside the brief. Remove the key.`
|
|
246
|
+
)
|
|
247
|
+
}
|
|
238
248
|
refuseQueryRoute(decl, where)
|
|
239
249
|
refuseLimit(decl.limit, where)
|
|
240
250
|
// ⛔ `sync:` IS RETIRED (2026-09-21). `sync: false` kept a query's records off a
|
|
@@ -303,6 +313,11 @@ function refuseMethod(method, where) {
|
|
|
303
313
|
* downstream. It is stripped here rather than at each consumer, so the payload
|
|
304
314
|
* has one shape and no consumer has to know the field existed.
|
|
305
315
|
*
|
|
316
|
+
* ⛔ `name` too: a declaration carries its query's name for the build's own messages, and
|
|
317
|
+
* `config.queries` is keyed by it — repeated inside, it was a second copy nothing read, and it
|
|
318
|
+
* held a key a declaration may want for something else [Diego, 2026-09-27]. Until then every
|
|
319
|
+
* query on the payload read `{ name: 'articles', … }` under `articles`.
|
|
320
|
+
*
|
|
306
321
|
* Returns undefined for a site with no queries, so `config.queries`
|
|
307
322
|
* stays absent rather than becoming an empty object — an empty object reads as
|
|
308
323
|
* "declared, and empty" to anything checking for presence.
|
|
@@ -311,90 +326,14 @@ export function toConfigQueries(declarations) {
|
|
|
311
326
|
const names = Object.keys(declarations || {})
|
|
312
327
|
if (names.length === 0) return undefined
|
|
313
328
|
const out = {}
|
|
314
|
-
for (const
|
|
315
|
-
const { schemaExplicit, ...rest } = declarations[
|
|
316
|
-
out[
|
|
329
|
+
for (const key of names) {
|
|
330
|
+
const { schemaExplicit, name, ...rest } = declarations[key]
|
|
331
|
+
out[key] = rest
|
|
317
332
|
}
|
|
318
333
|
return out
|
|
319
334
|
}
|
|
320
335
|
|
|
321
336
|
|
|
322
|
-
/**
|
|
323
|
-
* Fill in `deferred:` from each collection's own data schema.
|
|
324
|
-
*
|
|
325
|
-
* ⭐ A schema's **brief** section already states what a record's summary is — the
|
|
326
|
-
* card, the row, the thing a list shows. Everything else is wanted only when one
|
|
327
|
-
* record is the focus. That is exactly what `deferred:` says, so an author with a
|
|
328
|
-
* schema should not have to say it twice, in a second vocabulary, with nothing
|
|
329
|
-
* checking the two against each other.
|
|
330
|
-
*
|
|
331
|
-
* ⇒ `deferred` = every top-level section of the schema other than its brief — the keys
|
|
332
|
-
* a delivered record holds them under (`article_body` for `@std/article`), which is
|
|
333
|
-
* exactly what a host's records service leaves out of a list and adds for `whole`.
|
|
334
|
-
* ⛔ It was the flat form's fields minus the brief's until 2026-09-24; a delivered
|
|
335
|
-
* record carries those under their section, so stripping them by name stripped nothing.
|
|
336
|
-
*
|
|
337
|
-
* Derived from the SCHEMA, never from a record. That is what keeps the
|
|
338
|
-
* build-derived keys safe without a reserved list: `slug`, `route`, `path`,
|
|
339
|
-
* `excerpt`, `image` and `lastModified` are not sections, so they are never
|
|
340
|
-
* stripped. A markdown body goes where its content field is — inside `article_body`,
|
|
341
|
-
* say — so it is deferred with that section.
|
|
342
|
-
*
|
|
343
|
-
* ⛔ Silent on every path that cannot answer, because none of them is an error:
|
|
344
|
-
*
|
|
345
|
-
* - an author-declared `deferred:` wins outright — this never overrides one;
|
|
346
|
-
* - no local foundation (a linked or cataloged one), or it is unbuilt → nothing
|
|
347
|
-
* to read, and a site must still build;
|
|
348
|
-
* - the schema is not in the foundation's built map → the same soft-skip the
|
|
349
|
-
* sync lane already applies. `dist/meta/schema.json` carries the schemas
|
|
350
|
-
* COMPONENTS reference, so a collection whose schema no component binds is
|
|
351
|
-
* simply not there;
|
|
352
|
-
* - the schema has no brief, or only one section (`recordLayout`) → there is no
|
|
353
|
-
* lean shape to honour, so records stay whole.
|
|
354
|
-
*
|
|
355
|
-
* The last two are why this reads the built artifact rather than resolving
|
|
356
|
-
* schemas itself: it is the same input the sync lane uses, so both lanes agree
|
|
357
|
-
* about which schemas exist.
|
|
358
|
-
*/
|
|
359
|
-
async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
|
|
360
|
-
const pending = Object.values(declarations).filter(
|
|
361
|
-
(d) => d.schema && !Array.isArray(d.deferred)
|
|
362
|
-
)
|
|
363
|
-
if (pending.length === 0) return
|
|
364
|
-
|
|
365
|
-
const dataSchemas = foundationDataSchemas(siteRoot, siteYml)
|
|
366
|
-
if (!dataSchemas) return
|
|
367
|
-
|
|
368
|
-
for (const decl of pending) {
|
|
369
|
-
const heavy = deferredFromSchema(dataSchemas[decl.schema])
|
|
370
|
-
if (heavy) decl.deferred = heavy
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* The `deferred:` a schema implies — every top-level section but its **brief**, by name.
|
|
376
|
-
*
|
|
377
|
-
* ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
|
|
378
|
-
* above uses it to FILL an unstated `deferred:`; `uwx/records-project.js` uses it to
|
|
379
|
-
* RECOGNIZE a derived value on the way back in, so a pull does not write a derivation
|
|
380
|
-
* into the author's file as though they had typed it.
|
|
381
|
-
*
|
|
382
|
-
* ⚠️ A second copy would drift, and the drift would be invisible: the deriver and the
|
|
383
|
-
* inverter would simply stop agreeing about which values are "the derived one", and the
|
|
384
|
-
* pull would start persisting values it was written to drop.
|
|
385
|
-
*
|
|
386
|
-
* @param {object|undefined} schema a data schema, or undefined when it does not resolve
|
|
387
|
-
* @returns {string[]|null} the implied deferred list, or null when the schema states no
|
|
388
|
-
* brief (a root list, say) or implies nothing heavy — in both cases there is no
|
|
389
|
-
* derivation to recognize
|
|
390
|
-
*/
|
|
391
|
-
export function deferredFromSchema(schema) {
|
|
392
|
-
const layout = schema ? recordLayout(schema) : null
|
|
393
|
-
if (!layout || layout.flat || !layout.brief) return null
|
|
394
|
-
const heavy = layout.sections.map(([name]) => name).filter((name) => name !== layout.brief)
|
|
395
|
-
return heavy.length ? heavy : null
|
|
396
|
-
}
|
|
397
|
-
|
|
398
337
|
/**
|
|
399
338
|
* The data schemas a site's records are shaped by, keyed by ref — each resolved from
|
|
400
339
|
* the LOCAL foundation's SOURCE by the resolver its build uses (`buildDataSchemaMap`),
|
|
@@ -446,6 +385,30 @@ function localFoundationSrcDir(siteRoot, siteYml) {
|
|
|
446
385
|
}
|
|
447
386
|
}
|
|
448
387
|
|
|
388
|
+
/**
|
|
389
|
+
* A site's foundation's section types, each with its `data:` — what types a query named for a data
|
|
390
|
+
* key (`uwx/data-key-types.js`). Read from the foundation's SOURCE when it is local, as
|
|
391
|
+
* `resolveRecordSchemas` reads its data schemas, so `pnpm dev` without a built foundation types a
|
|
392
|
+
* query as a production build does; else from its built or registered `schema.json`
|
|
393
|
+
* (`foundationSchemaJson`). Null when there is neither.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} siteRoot
|
|
396
|
+
* @param {object} [siteYml] - an already-read site.yml
|
|
397
|
+
* @returns {Promise<object|null>}
|
|
398
|
+
*/
|
|
399
|
+
export async function foundationSections(siteRoot, siteYml = null) {
|
|
400
|
+
const yml = siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
|
|
401
|
+
const srcDir = localFoundationSrcDir(siteRoot, yml)
|
|
402
|
+
if (srcDir) {
|
|
403
|
+
try {
|
|
404
|
+
return await buildSchema(srcDir)
|
|
405
|
+
} catch {
|
|
406
|
+
// a foundation whose source does not read is reported by the build that builds it
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return foundationSchemaJson(siteRoot, yml)
|
|
410
|
+
}
|
|
411
|
+
|
|
449
412
|
/** The data schemas a site's foundation declares, or null when unresolvable. */
|
|
450
413
|
export function foundationDataSchemas(siteRoot, siteYml) {
|
|
451
414
|
return foundationSchemaJson(siteRoot, siteYml)?.dataSchemas || null
|