@uniweb/build 0.29.1 → 0.30.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 +5 -5
- 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 +35 -40
- package/src/site/data-fetcher.js +23 -10
- package/src/site/entity-pool.js +211 -0
- package/src/site/fetch-shapes.js +13 -12
- 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 +92 -77
- package/src/uwx/index.js +33 -13
- 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 +6 -6
- package/src/uwx/site-project.js +30 -5
- package/src/uwx/site.js +295 -27
- package/src/uwx/sync-package.js +32 -18
- 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
package/src/uwx/site.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// The document mirrors the @uniweb/site-content Model: `info` (brief) · `pages`
|
|
12
12
|
// (self-nesting; each page carries its `page_sections` as an inline field) ·
|
|
13
|
-
// `layout_sections` · `extensions` · `
|
|
13
|
+
// `layout_sections` · `extensions` · `queries`. `info.foundation`
|
|
14
14
|
// carries the verbatim `site.yml::foundation` string (the round-trip source of
|
|
15
15
|
// truth).
|
|
16
16
|
//
|
|
@@ -54,13 +54,13 @@ import {
|
|
|
54
54
|
processMarkdownFile,
|
|
55
55
|
} from '../site/content-collector.js'
|
|
56
56
|
import { normalizeHideIn } from '../site/nav-visibility.js'
|
|
57
|
-
import { resolveDefaultLocale, validateLanguageConfig,
|
|
57
|
+
import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
|
|
58
58
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
59
59
|
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
60
60
|
import { unwrapLocalized } from './backfill.js'
|
|
61
61
|
import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
62
62
|
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
63
|
-
import {
|
|
63
|
+
import { resolveQueriesConfig } from './queries-config.js'
|
|
64
64
|
|
|
65
65
|
const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
|
|
66
66
|
|
|
@@ -223,36 +223,43 @@ function buildPageData(config, ctx) {
|
|
|
223
223
|
let fetch =
|
|
224
224
|
config.fetch ??
|
|
225
225
|
(config.data
|
|
226
|
-
? {
|
|
226
|
+
? { query: Array.isArray(config.data) ? config.data[0] : config.data }
|
|
227
227
|
: undefined)
|
|
228
|
-
// Resolve the
|
|
228
|
+
// Resolve the authored `query:` shorthand to the runtime-fetchable
|
|
229
229
|
// `path: /data/<name>.json` (the static convention the default-fetcher uses).
|
|
230
230
|
// A shell/backend-hosted site renders client-side with NO prerender, so the
|
|
231
|
-
// runtime fetches this decl directly — and `
|
|
231
|
+
// runtime fetches this decl directly — and `query:` is build-time-only, so
|
|
232
232
|
// it would never resolve at render (the static build resolves it the same way
|
|
233
233
|
// in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
|
|
234
234
|
// at `<base>/data/<name>.json`.
|
|
235
|
-
if (fetch && typeof fetch.
|
|
236
|
-
const {
|
|
235
|
+
if (fetch && typeof fetch.query === 'string') {
|
|
236
|
+
const { query, ...rest } = fetch
|
|
237
237
|
// ⭐ BOTH, deliberately, and they are not redundant.
|
|
238
238
|
//
|
|
239
|
-
// `
|
|
240
|
-
// a host where
|
|
241
|
-
//
|
|
242
|
-
//
|
|
239
|
+
// `query` — the author's named query, unresolved. A consumer that can ask
|
|
240
|
+
// a host where records live (`config.records`) resolves it there, which
|
|
241
|
+
// is the only way a live lane is reachable at all: a resolved path names
|
|
242
|
+
// one place and closes the question.
|
|
243
243
|
//
|
|
244
244
|
// `path` — the compiled artifact, the answer when nobody declares a lane.
|
|
245
245
|
// Also what a consumer still reading `fetch.path` gets, so teaching the
|
|
246
246
|
// wire a new field does not break one that has not learned it.
|
|
247
247
|
//
|
|
248
|
-
// `@uniweb/core`'s resolveFetchConfigs gives
|
|
249
|
-
//
|
|
250
|
-
//
|
|
248
|
+
// `@uniweb/core`'s resolveFetchConfigs gives the query precedence and drops
|
|
249
|
+
// `path` once it has resolved an address — matching parseFetchConfig, which
|
|
250
|
+
// has always returned early on the shorthand.
|
|
251
251
|
//
|
|
252
|
-
// `schema` (the
|
|
252
|
+
// `schema` (the query name) is BOTH the content.data key and part of the
|
|
253
253
|
// dataStore cache key (deriveCacheKey hashes {path,url,endpoint,schema,…};
|
|
254
|
-
//
|
|
255
|
-
|
|
254
|
+
// the shorthand is ignored). Mirrors the static build's parseFetchConfig.
|
|
255
|
+
// ⭐ `query`, END TO END — no crossing. An earlier version emitted `collection`
|
|
256
|
+
// here on the belief that this field was the backend's to name. MEASURED
|
|
257
|
+
// otherwise: framework already ships `transform`, `detailPage`, `merge` and
|
|
258
|
+
// `prerender` inside this same `fetch` object, which no backend could be
|
|
259
|
+
// validating — so `fetch` is a blob they carry and framework owns its
|
|
260
|
+
// vocabulary. ⇒ There was nothing to coordinate, and inventing a coordination
|
|
261
|
+
// is how a name stays wrong.
|
|
262
|
+
fetch = { query, path: queryDataUrl(query), schema: query, ...rest }
|
|
256
263
|
}
|
|
257
264
|
setIf(data, 'fetch', fetch)
|
|
258
265
|
if (isDynamic) {
|
|
@@ -667,11 +674,91 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
667
674
|
* rename every collection at once and the section goes all-blank and is refused.
|
|
668
675
|
* That is the semantics, not a defect.
|
|
669
676
|
*
|
|
677
|
+
* ⛔ DO NOT TRIM THE FIELDS BELOW, even the ones the backend never reads.
|
|
678
|
+
*
|
|
679
|
+
* The backend destructures exactly two — `name` and `schema` — and projects none of
|
|
680
|
+
* this Section into a published payload, so the rest read as dead weight. ⛔ THEY ARE
|
|
681
|
+
* OURS, AND THAT IS REASON ENOUGH: `excerpt`, `deferred`, `detailUrl` and `queryable`
|
|
682
|
+
* are read across FRAMEWORK's own runtime, build and kit — `useQueryable`
|
|
683
|
+
* is a public hook a foundation calls to render a filter UI. They drive the file
|
|
684
|
+
* lane, where they work. "The backend does not read it" was never an argument that
|
|
685
|
+
* nothing reads it.
|
|
686
|
+
*
|
|
687
|
+
* ⚠️ There may be a second reason, and it is NOT ours to assert. Backend states that
|
|
688
|
+
* their decl type reaches the app lane verbatim and that their reconcile
|
|
689
|
+
* replaces an item's `data` wholesale with no field-grain merge — from which an
|
|
690
|
+
* omitted field the EDITOR set would be destroyed on the next push. The mechanism is
|
|
691
|
+
* their code and theirs to state. **Whether the editor reads or writes this decl at
|
|
692
|
+
* all is FRONTEND's, and neither framework nor backend has established it.** Treat it
|
|
693
|
+
* as an open hypothesis, not a fact — see the doc below.
|
|
694
|
+
*
|
|
695
|
+
* ⚠️ Separately measured: framework does not emit `label` and has no such collection
|
|
696
|
+
* field — ours belongs to a `folders:` BRANCH. Backend's fixture asserted we mirror a
|
|
697
|
+
* `site.yml collections.<name>.label`; no such field has ever existed, and they have
|
|
698
|
+
* corrected it.
|
|
699
|
+
*
|
|
700
|
+
* ⇒ Full record, including what is established vs merely claimed:
|
|
701
|
+
* `kb/framework/build/collections-decl-open-questions.md`.
|
|
702
|
+
*
|
|
670
703
|
* @param {object} declarations resolved collection declarations, keyed by name
|
|
671
704
|
* @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
|
|
672
705
|
* response or a pull. Absent on a first sync, where minting is correct.
|
|
673
706
|
*/
|
|
674
|
-
|
|
707
|
+
// ⛔ KEYS THAT MUST NOT REACH THE WIRE. Everything else on an authored declaration
|
|
708
|
+
// is emitted, including fields this build does not model — see the note in
|
|
709
|
+
// `queriesNested`. Enumerated here rather than inverted into an allowlist
|
|
710
|
+
// because framework OWNS this vocabulary and can therefore enumerate it
|
|
711
|
+
// truthfully; it does not own the Model's, and cannot.
|
|
712
|
+
//
|
|
713
|
+
// Sources, both framework's own: `site/query-processor.js::parseQueryConfig`
|
|
714
|
+
// (the decl parser) and `site/queries-config.js` (normalization). Pinned by
|
|
715
|
+
// `tests/uwx-decl-unmodelled-fields.test.js`, which fails if either gains a field
|
|
716
|
+
// that is neither emitted nor listed here.
|
|
717
|
+
// Authored keys the explicit block in `queriesNested` already consumes. Kept
|
|
718
|
+
// separate from the framework-local set below because these DO reach the wire —
|
|
719
|
+
// just under a wire spelling. ⚠️ `detailUrl` is the one that matters: it is emitted
|
|
720
|
+
// as `detail_url`, so a pass-through keyed on "is it already in `data`?" does not
|
|
721
|
+
// see it and the field rides TWICE. Measured 2026-08-29, in the first draft of this
|
|
722
|
+
// very change — and the push test missed it because both its controls (`limit`,
|
|
723
|
+
// `schema`) keep their names.
|
|
724
|
+
const DECL_EMITTED_ABOVE = new Set([
|
|
725
|
+
'source',
|
|
726
|
+
'schema',
|
|
727
|
+
'sort',
|
|
728
|
+
'where',
|
|
729
|
+
'limit',
|
|
730
|
+
'excerpt',
|
|
731
|
+
'deferred',
|
|
732
|
+
'detailUrl',
|
|
733
|
+
'queryable'
|
|
734
|
+
])
|
|
735
|
+
|
|
736
|
+
const DECL_NOT_ON_WIRE = new Set([
|
|
737
|
+
// Identity — rides as the record's own `name`, not inside `data`.
|
|
738
|
+
'name',
|
|
739
|
+
// Folded into `source` above.
|
|
740
|
+
'path',
|
|
741
|
+
'url',
|
|
742
|
+
// Folded into `schema` above (the migration synonym).
|
|
743
|
+
'model',
|
|
744
|
+
// Build state: whether the AUTHOR asked for the schema or the subfolder-name
|
|
745
|
+
// convention supplied it. Decides hard-error vs soft-skip during sync;
|
|
746
|
+
// `collections-config.js::toConfigQueries` strips it downstream too.
|
|
747
|
+
'schemaExplicit',
|
|
748
|
+
// ⭐ FRAMEWORK-LOCAL, and the one that proves the rule. `route:` is a real
|
|
749
|
+
// authored field — `parseQueryConfig` reads it, and `collectItems` composes
|
|
750
|
+
// each item's link as `<route>/<slug>` — but the backend's Model has no slot for
|
|
751
|
+
// it, so emitting it would be sending build-time config to a store that validates
|
|
752
|
+
// against a declared schema. Measured 2026-08-29: a first version of this change
|
|
753
|
+
// passed unknown keys through blindly and would have started sending `route` from
|
|
754
|
+
// every site that declares one.
|
|
755
|
+
'route',
|
|
756
|
+
// Legacy predicate, translated to the canonical `where` upstream. No legacy
|
|
757
|
+
// fields on the wire.
|
|
758
|
+
'filter'
|
|
759
|
+
])
|
|
760
|
+
|
|
761
|
+
function queriesNested(declarations, uuids = null) {
|
|
675
762
|
const out = []
|
|
676
763
|
for (const [name, d] of Object.entries(declarations)) {
|
|
677
764
|
const data = {}
|
|
@@ -687,6 +774,33 @@ function collectionsNested(declarations, uuids = null) {
|
|
|
687
774
|
setIf(data, 'deferred', d.deferred)
|
|
688
775
|
setIf(data, 'detail_url', d.detailUrl)
|
|
689
776
|
setIf(data, 'queryable', d.queryable)
|
|
777
|
+
// ⛔ EMIT WHAT WE DO NOT MODEL. The decl's field set is the BACKEND's Model
|
|
778
|
+
// (this document mirrors `@uniweb/site-content` — see the lane header), and
|
|
779
|
+
// their reconcile replaces `data` WHOLESALE with no field-grain merge. So an
|
|
780
|
+
// allowlist here does not merely fail to send an unmodelled field: it DESTROYS
|
|
781
|
+
// whatever was stored under it, silently, on every push.
|
|
782
|
+
//
|
|
783
|
+
// ⚠️ Measured 2026-08-29: the Model declares ELEVEN decl fields and this emitter
|
|
784
|
+
// knew ten. The eleventh is `label`, which framework has no authoring concept
|
|
785
|
+
// for — `label` in framework is a `folders:` BRANCH field (`{segment, label,
|
|
786
|
+
// entries}`), not a property of a collection.
|
|
787
|
+
//
|
|
788
|
+
// ⭐ `label` is the instance, not the defect. Any field the Model gains that we
|
|
789
|
+
// have not taught this function repeats it, and nothing reports the loss. Hence
|
|
790
|
+
// a DENY-list: framework can enumerate its own vocabulary truthfully and cannot
|
|
791
|
+
// enumerate the Model's, so the safe inversion is "withhold what is ours".
|
|
792
|
+
//
|
|
793
|
+
// ⚖️ We do NOT warn on an unrecognized key. Framework cannot tell a valid Model
|
|
794
|
+
// field from a typo — only the server can, and it validates every write against
|
|
795
|
+
// the declared schema. Its rejection is the honest signal; a guess from here
|
|
796
|
+
// would cry wolf on every legitimate new field. Same rule and same reasoning as
|
|
797
|
+
// `site/fetch-shapes.js`: drop only what is DERIVABLE, never what is merely
|
|
798
|
+
// unrecognized.
|
|
799
|
+
for (const [key, value] of Object.entries(d)) {
|
|
800
|
+
if (value === undefined) continue
|
|
801
|
+
if (DECL_EMITTED_ABOVE.has(key) || DECL_NOT_ON_WIRE.has(key)) continue
|
|
802
|
+
data[key] = value
|
|
803
|
+
}
|
|
690
804
|
const rec = withIdentity(name, { name, ...data })
|
|
691
805
|
const uuid = uuids?.[name]
|
|
692
806
|
if (typeof uuid === 'string' && uuid) rec.$uuid = uuid
|
|
@@ -695,6 +809,121 @@ function collectionsNested(declarations, uuids = null) {
|
|
|
695
809
|
return out
|
|
696
810
|
}
|
|
697
811
|
|
|
812
|
+
// ── `services` + `secrets` — a site's own service records ─────────────────────
|
|
813
|
+
//
|
|
814
|
+
// ⭐ THE FILE KEYS ARE `$services` / `$secrets`, NOT `services` / `secrets`, and the
|
|
815
|
+
// `$` is load-bearing rather than decorative. `site.yml::services` is ALREADY TAKEN,
|
|
816
|
+
// on the other lane: the bundle lane spreads site.yml whole into the payload, so a
|
|
817
|
+
// `services:` block there lands at `config.services` — the HOST tier — which is the
|
|
818
|
+
// documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
|
|
819
|
+
// Reusing the name would give one key two meanings that differ per lane, which is
|
|
820
|
+
// the shape of bug nobody finds.
|
|
821
|
+
//
|
|
822
|
+
// `$` already means "backend-scoped, round-tripped, not hand-authored" in this file
|
|
823
|
+
// (`$uuid`, `$org`, `$backend`), and that is exactly what these are: a service's
|
|
824
|
+
// config is bound where the service is provisioned, and arrives here by `pull`.
|
|
825
|
+
//
|
|
826
|
+
// ⚖️ WHAT THESE ARE NOT. A site's OWN service declarations — `search:`, `submit:`,
|
|
827
|
+
// `assistant:`, `tracking:` — stay top-level `info.*` keys and are untouched. Those
|
|
828
|
+
// are authored, they resolve at the SITE tier (`config.<name>`, first choice in
|
|
829
|
+
// `@uniweb/core`'s `resolveService`), and moving them here would flip them to the
|
|
830
|
+
// host tier, where a block's mere presence declines every service it does not name.
|
|
831
|
+
// These Sections carry the services a site is PROVISIONED with — `api` above all,
|
|
832
|
+
// which has no file-authored form because it is bought, not declared.
|
|
833
|
+
//
|
|
834
|
+
// ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
|
|
835
|
+
// REPLACED by what we send, so `[]` means "drop every stored config row" while a
|
|
836
|
+
// missing key means "I am not telling you about this". A project that has never
|
|
837
|
+
// pulled has no `$services`, and its ordinary push must not read as a request to
|
|
838
|
+
// wipe a service the operator configured in the app. So: emit the Section only when
|
|
839
|
+
// the file declares the key. Clearing is available and explicit — `$services: []`.
|
|
840
|
+
//
|
|
841
|
+
// ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
|
|
842
|
+
// tokens live in a gitignored per-clone cache, so a fresh clone pushes
|
|
843
|
+
// unconditionally. Correctness has to sit here.
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* `$services` / `$secrets` → Section records, or undefined when the key is absent.
|
|
847
|
+
*
|
|
848
|
+
* ⭐ PASSTHROUGH, NOT AN ALLOWLIST — the same rule and the same reason as
|
|
849
|
+
* `queriesNested` above. The field set belongs to the backend's Model, a service's
|
|
850
|
+
* `config` is opaque and per-service, and reconcile replaces `data` wholesale — so
|
|
851
|
+
* enumerating keys here would not merely fail to send a field we do not know, it
|
|
852
|
+
* would DESTROY whatever is stored under it on every push. Framework can enumerate
|
|
853
|
+
* its own vocabulary and cannot enumerate theirs; withhold ours, forward the rest.
|
|
854
|
+
*
|
|
855
|
+
* @param {*} declared - the raw `$services` / `$secrets` value from site.yml
|
|
856
|
+
* @param {(entry: object) => string|null} identify - the record's stable `$id`
|
|
857
|
+
* @param {string} label - the key name, for the one warning below
|
|
858
|
+
* @returns {object[]|undefined}
|
|
859
|
+
*/
|
|
860
|
+
function serviceRecords(declared, identify, label) {
|
|
861
|
+
if (declared === undefined || declared === null) return undefined
|
|
862
|
+
if (!Array.isArray(declared)) {
|
|
863
|
+
console.warn(
|
|
864
|
+
`uwx/site: \`${label}:\` must be a list of entries — ignoring a ${typeof declared}.`
|
|
865
|
+
)
|
|
866
|
+
return undefined
|
|
867
|
+
}
|
|
868
|
+
const out = []
|
|
869
|
+
for (const entry of declared) {
|
|
870
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
871
|
+
const id = identify(entry)
|
|
872
|
+
// ⚠️ `name` is OUR OWN declared-required field, so a warning here is honest —
|
|
873
|
+
// unlike an unrecognized key, which only the server can judge. And the loss is
|
|
874
|
+
// otherwise invisible: on a replaced Section a dropped entry reads as a
|
|
875
|
+
// deliberate removal of its config row.
|
|
876
|
+
if (!id) {
|
|
877
|
+
console.warn(
|
|
878
|
+
`uwx/site: \`${label}:\` has an entry with no \`name\` — skipping it. On a replaced ` +
|
|
879
|
+
'section a dropped entry reads as a deliberate removal of its config.'
|
|
880
|
+
)
|
|
881
|
+
continue
|
|
882
|
+
}
|
|
883
|
+
const data = {}
|
|
884
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
885
|
+
if (value === undefined) continue
|
|
886
|
+
data[key] = value
|
|
887
|
+
}
|
|
888
|
+
out.push(withIdentity(id, data))
|
|
889
|
+
}
|
|
890
|
+
return out
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** One service per `name` — the same keyspace `config.services` uses at runtime. */
|
|
894
|
+
function servicesNested(siteYml) {
|
|
895
|
+
return serviceRecords(
|
|
896
|
+
siteYml.$services,
|
|
897
|
+
(e) => (typeof e.name === 'string' && e.name ? e.name : null),
|
|
898
|
+
'$services'
|
|
899
|
+
)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* One secret per `(service, name)` — the pair the backend merges on. A site-level
|
|
904
|
+
* secret belongs to no service, so `service` is optional and the handle degrades to
|
|
905
|
+
* the bare name.
|
|
906
|
+
*
|
|
907
|
+
* ⛔ `value` IS FORWARDED VERBATIM, INCLUDING A LITERAL. A pulled secret carries the
|
|
908
|
+
* marker `#ref` meaning "a secret is set", never the value, and pushing the marker
|
|
909
|
+
* back means "leave it alone" — so the ordinary round trip sends nothing sensitive.
|
|
910
|
+
* A literal typed into the file is refused by the server, which is where that
|
|
911
|
+
* judgement belongs; framework does not strip it, because silently dropping a value
|
|
912
|
+
* an author typed would leave them believing a secret was set.
|
|
913
|
+
*/
|
|
914
|
+
function secretsNested(siteYml) {
|
|
915
|
+
return serviceRecords(
|
|
916
|
+
siteYml.$secrets,
|
|
917
|
+
(e) => {
|
|
918
|
+
if (typeof e.name !== 'string' || !e.name) return null
|
|
919
|
+
return typeof e.service === 'string' && e.service
|
|
920
|
+
? `${e.service}:${e.name}`
|
|
921
|
+
: e.name
|
|
922
|
+
},
|
|
923
|
+
'$secrets'
|
|
924
|
+
)
|
|
925
|
+
}
|
|
926
|
+
|
|
698
927
|
/**
|
|
699
928
|
* Map a file site project to the nested `@uniweb/site-content` `$`-document
|
|
700
929
|
* (see the lane header above). PURE — reads the project, never mints, never writes.
|
|
@@ -709,7 +938,7 @@ function collectionsNested(declarations, uuids = null) {
|
|
|
709
938
|
* the site's effective default locale (`defaultLanguage || languages[0] ||
|
|
710
939
|
* 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
|
|
711
940
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
712
|
-
* `{ $uuid?, $id, $model, info, pages, layout_sections, extensions,
|
|
941
|
+
* `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, queries }`
|
|
713
942
|
*/
|
|
714
943
|
export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
715
944
|
const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
|
|
@@ -843,13 +1072,38 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
843
1072
|
// secret store is the only right home either way.
|
|
844
1073
|
|
|
845
1074
|
setIf(info, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
|
|
1075
|
+
// ⛔ `api` IS DELIBERATELY NOT HERE, and this note exists because every comment
|
|
1076
|
+
// above it argues the opposite — three services are on this allowlist precisely so
|
|
1077
|
+
// an authored block cannot work on a static host and vanish on the synced one.
|
|
1078
|
+
// Without this paragraph the next reader adds the missing fourth line and calls it
|
|
1079
|
+
// a bug fix.
|
|
1080
|
+
//
|
|
1081
|
+
// ⭐ `api` is the one service a site does not AUTHOR. It is a real backend that is
|
|
1082
|
+
// provisioned and paid for, so its address is the host's to supply — it arrives as
|
|
1083
|
+
// `config.services.api` and `@uniweb/api` reads it there (`resolveBase`). An
|
|
1084
|
+
// authored `api:` is the SITE tier, which outranks the host permanently.
|
|
1085
|
+
//
|
|
1086
|
+
// ⇒ Carrying it would turn a local-dev override into a production one the moment
|
|
1087
|
+
// someone pushed: the host would store `info.api` and serve it back as `config.api`,
|
|
1088
|
+
// which wins over the address of the backend the site actually has. The vanish on
|
|
1089
|
+
// this lane is the correct behaviour, not the bug the comments above describe —
|
|
1090
|
+
// there, a dropped block leaves a site with NO endpoint; here it leaves the site
|
|
1091
|
+
// with the RIGHT one.
|
|
1092
|
+
//
|
|
1093
|
+
// The provisioned record rides the `$services` section instead (see servicesNested).
|
|
846
1094
|
setIf(info, 'paths', siteYml.paths)
|
|
847
1095
|
setIf(info, 'data', siteYml.data ?? siteYml.fetch)
|
|
848
|
-
// `app` —
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
1096
|
+
// ⛔ `app` IS RETIRED — do not reintroduce it, in either direction. It carried an
|
|
1097
|
+
// opaque uuid naming a separate entity a host bound to the site; that entity is
|
|
1098
|
+
// gone, a site's services belong to the site itself, and NOTHING replaces the key.
|
|
1099
|
+
//
|
|
1100
|
+
// ⚠️ Removing the emit is the FIRST of two steps and the order is forced: a host
|
|
1101
|
+
// refuses a key it does not declare, so the producer stops sending before the
|
|
1102
|
+
// declaration is dropped. The reverse order fails every push in between.
|
|
1103
|
+
//
|
|
1104
|
+
// ✅ Unobservable, because nothing ever originated the key — no template writes
|
|
1105
|
+
// `site.yml::app`. The line round-tripped a value that was never set.
|
|
1106
|
+
// (uwx-format.md → info.app.)
|
|
853
1107
|
// `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
|
|
854
1108
|
// backend applies a clonability designation to this site-content entity (it is
|
|
855
1109
|
// NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
|
|
@@ -881,7 +1135,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
881
1135
|
|
|
882
1136
|
// Collection DECLARATIONS — the merged collections.yml + site.yml::collections
|
|
883
1137
|
// config (the records themselves are separate entities; this is just the config).
|
|
884
|
-
const colConfig = await
|
|
1138
|
+
const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
|
|
885
1139
|
|
|
886
1140
|
// `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
|
|
887
1141
|
// `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
|
|
@@ -895,7 +1149,21 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
895
1149
|
doc.pages = pages
|
|
896
1150
|
doc.layout_sections = layoutSections
|
|
897
1151
|
doc.extensions = extensionsNested(siteYml)
|
|
898
|
-
|
|
1152
|
+
// ⭐ THE SECTION IS `queries`. Backend renamed it on `@uniweb/site-content`
|
|
1153
|
+
// (2026-08-29) and explains it as named queries — content that is RESOLVED AT
|
|
1154
|
+
// RUNTIME rather than rendered, which is framework's own model of it
|
|
1155
|
+
// (`records-model.md` §1: a query is second-order site content).
|
|
1156
|
+
//
|
|
1157
|
+
// ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
|
|
1158
|
+
// consumer sees, leave the identifier alone.
|
|
1159
|
+
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids)
|
|
1160
|
+
// Emitted ONLY when the file declares the key — see the header above
|
|
1161
|
+
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1162
|
+
// requests and one of them is destructive.
|
|
1163
|
+
const services = servicesNested(siteYml)
|
|
1164
|
+
if (services) doc.services = services
|
|
1165
|
+
const secrets = secretsNested(siteYml)
|
|
1166
|
+
if (secrets) doc.secrets = secrets
|
|
899
1167
|
return doc
|
|
900
1168
|
}
|
|
901
1169
|
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// lanes, each its own `.uwx`:
|
|
3
3
|
//
|
|
4
4
|
// - site-content lane → one `@uniweb/site-content` entity (the static half).
|
|
5
|
-
// -
|
|
5
|
+
// - records lane → one `@uniweb/folder` entity + the record entities it
|
|
6
6
|
// references (the dynamic half; the `$ref` closure rides
|
|
7
7
|
// together so brand-new records resolve in one call).
|
|
8
8
|
//
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
//
|
|
15
15
|
// "Send only changed" spans both lanes via one content-hash map (the sync-cache):
|
|
16
16
|
// - site-content lane fires iff the site entity changed.
|
|
17
|
-
// -
|
|
17
|
+
// - records lane fires iff the folder changed OR any record changed — and when
|
|
18
18
|
// it fires it carries the FULL folder (for the `$ref` closure + binding) plus the
|
|
19
|
-
// changed records. An untouched site with
|
|
19
|
+
// changed records. An untouched site with records pushes nothing on either
|
|
20
20
|
// lane (the idempotent no-op).
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import { buildRecordEntities, entityContentHash } from './records.js'
|
|
23
23
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
24
24
|
import { buildFolderEntity } from './folder.js'
|
|
25
25
|
import { siteProjectToDocument } from './site.js'
|
|
@@ -235,11 +235,11 @@ function rewriteEntityAssets(node, map, ids) {
|
|
|
235
235
|
* @param {object} [opts.exporter] @param {string} [opts.exportedAt]
|
|
236
236
|
* @returns {Promise<{
|
|
237
237
|
* siteContent: { buffer, entityCount, index, models }|null,
|
|
238
|
-
*
|
|
238
|
+
* records: { buffer, entityCount, index, models }|null,
|
|
239
239
|
* hashes: Object<string,string>, warnings: string[], skipped: number,
|
|
240
240
|
* schemaless: Array<{name: string, model: string}>, localAssets: string[],
|
|
241
241
|
* applied: object }>}
|
|
242
|
-
* `schemaless` lists
|
|
242
|
+
* `schemaless` lists queries that resolved no data schema (soft-skipped from
|
|
243
243
|
* the sync) — the composite deploy delivers these statically via the data ball.
|
|
244
244
|
* `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
|
|
245
245
|
* must upload + rewrite to serve URLs; co-located refs are warned and skipped.
|
|
@@ -247,7 +247,7 @@ function rewriteEntityAssets(node, map, ids) {
|
|
|
247
247
|
* (`assetRewrite` / `assetIds` / `injectInfo` / `injectExtensions`), ready to be
|
|
248
248
|
* passed straight back as opts. A caller that banks the `hashes` must bank this
|
|
249
249
|
* beside them, or an offline re-emit cannot reproduce the document they describe.
|
|
250
|
-
* Each lane is null when it has nothing to push. The
|
|
250
|
+
* Each lane is null when it has nothing to push. The records `index` keeps a
|
|
251
251
|
* leading `{ kind: 'folder' }` placeholder (submission position 0 → the folder
|
|
252
252
|
* entity) so record back-fill stays positionally aligned; the folder itself has no
|
|
253
253
|
* uuid to back-fill.
|
|
@@ -261,22 +261,31 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
261
261
|
const exporter = opts.exporter
|
|
262
262
|
const exportedAt = opts.exportedAt
|
|
263
263
|
|
|
264
|
-
const col = await
|
|
264
|
+
const col = await buildRecordEntities(siteRoot, {
|
|
265
265
|
...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
|
|
266
266
|
...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
|
|
267
267
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
268
268
|
// The publish org — resolves a foundation-relative `@/x` model ref into
|
|
269
269
|
// `@org/x` before it ships. Absent on an offline probe, which is why
|
|
270
|
-
//
|
|
270
|
+
// buildRecordEntities warns rather than throws.
|
|
271
271
|
...(opts.org ? { org: opts.org } : {}),
|
|
272
272
|
})
|
|
273
|
-
const warnings = [...col.warnings]
|
|
273
|
+
const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
|
|
274
274
|
|
|
275
275
|
// The folder rides over the FULL record set (before filtering) so its references
|
|
276
276
|
// are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
|
|
277
277
|
const folder = buildFolderEntity({
|
|
278
278
|
recordEntities: col.entities,
|
|
279
|
-
|
|
279
|
+
// ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
|
|
280
|
+
// collection — which made the folder a shadow of a directory layout rather
|
|
281
|
+
// than something the author states.
|
|
282
|
+
folderNodes: col.folder?.nodes ?? [],
|
|
283
|
+
// ⛔ Whether `records.yml` EXISTS — not whether it holds anything. Missing is
|
|
284
|
+
// inert; empty is a folder that REMOVES. Compared against the affirmative
|
|
285
|
+
// value, never `!== 'missing'`: an absent state would read as declared, and
|
|
286
|
+
// that is precisely how a site with no records.yml once emitted a folder that
|
|
287
|
+
// would have emptied the live one.
|
|
288
|
+
declared: col.recordsState === 'empty' || col.recordsState === 'declared',
|
|
280
289
|
// Placement identity from the folder document a previous push returned.
|
|
281
290
|
// Absent on a first push — every item is genuinely new then. Its ABSENCE on a
|
|
282
291
|
// later push is what made `publish` after `push` fail: send-only-changed skips
|
|
@@ -284,13 +293,18 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
284
293
|
// the payload whose item identity has to survive.
|
|
285
294
|
...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
|
|
286
295
|
})
|
|
296
|
+
// ⚠️ A placement that produced no entity is dropped by the builder rather than
|
|
297
|
+
// sent pointing at nothing — but it must still be SAID. It means an entity was
|
|
298
|
+
// placed in records.yml and then skipped upstream (a schema that did not
|
|
299
|
+
// resolve, most often), and the record is simply absent from the site.
|
|
300
|
+
if (folder?.warnings?.length) warnings.push(...folder.warnings)
|
|
287
301
|
|
|
288
|
-
// `
|
|
289
|
-
// NAME because a declaration has no file of its own (see
|
|
302
|
+
// `queryUuids` — identity for the `queries` section, keyed by query
|
|
303
|
+
// NAME because a declaration has no file of its own (see queriesNested).
|
|
290
304
|
const siteDoc = includeSite
|
|
291
305
|
? await siteProjectToDocument(siteRoot, {
|
|
292
306
|
sourceLocale,
|
|
293
|
-
...(opts.
|
|
307
|
+
...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {})
|
|
294
308
|
})
|
|
295
309
|
: null
|
|
296
310
|
// Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
|
|
@@ -398,14 +412,14 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
398
412
|
return isChanged
|
|
399
413
|
}
|
|
400
414
|
|
|
401
|
-
// ---
|
|
415
|
+
// --- records lane ------------------------------------------------------------
|
|
402
416
|
// changed() has side effects (hashes/skipped), so evaluate every entity exactly
|
|
403
417
|
// once, in a stable order: folder, then each record.
|
|
404
418
|
const folderChanged = folder ? changed(folder) : false
|
|
405
419
|
const recordChanged = col.entities.map((e, i) => ({ entity: e, index: col.index[i], changed: changed(e) }))
|
|
406
420
|
const changedRecords = recordChanged.filter((r) => r.changed)
|
|
407
421
|
|
|
408
|
-
let
|
|
422
|
+
let records = null
|
|
409
423
|
if (folder && (folderChanged || changedRecords.length > 0)) {
|
|
410
424
|
// Folder first (always, for the `$ref` closure), then changed records. The
|
|
411
425
|
// leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
|
|
@@ -416,7 +430,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
416
430
|
// filtered out here by send-only-changed. Declare them all (the backend rejects a
|
|
417
431
|
// folder that references an undeclared Model).
|
|
418
432
|
const referencedModels = [...collectReferencedModels(folder.document, new Set())]
|
|
419
|
-
|
|
433
|
+
records = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
|
|
420
434
|
}
|
|
421
435
|
|
|
422
436
|
// --- site-content lane -------------------------------------------------------
|
|
@@ -455,7 +469,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
455
469
|
}
|
|
456
470
|
|
|
457
471
|
return {
|
|
458
|
-
siteContent,
|
|
472
|
+
siteContent, records, siteContentUuid, hashes, warnings, skipped,
|
|
459
473
|
schemaless: col.schemaless, localAssets, applied,
|
|
460
474
|
// { stamped, unknown } when identity was applied; null when the caller passed
|
|
461
475
|
// no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
|
package/src/validate-data.js
CHANGED
|
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
27
27
|
import { existsSync } from 'node:fs'
|
|
28
28
|
import { join, resolve, basename } from 'node:path'
|
|
29
29
|
import yaml from 'js-yaml'
|
|
30
|
-
import {
|
|
30
|
+
import { queryNameFromUrl } from '@uniweb/core'
|
|
31
31
|
|
|
32
32
|
import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
|
|
33
33
|
import { validateAndNormalizeSchema } from './resolve-data-schema.js'
|
|
@@ -39,7 +39,7 @@ export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
|
39
39
|
import { buildSchema } from './schema.js'
|
|
40
40
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
41
41
|
import { collectSiteContent } from './site/content-collector.js'
|
|
42
|
-
import {
|
|
42
|
+
import { processQueries } from './site/query-processor.js'
|
|
43
43
|
|
|
44
44
|
// --- the join: sections ↔ schemas -------------------------------------------
|
|
45
45
|
|
|
@@ -58,7 +58,7 @@ import { processCollections } from './site/collection-processor.js'
|
|
|
58
58
|
* this command and the build disagree about what feeds what.
|
|
59
59
|
*
|
|
60
60
|
* Data is acquired without a full build: the foundation schema via schema
|
|
61
|
-
* discovery, the site sections via the content collector, the
|
|
61
|
+
* discovery, the site sections via the content collector, the byQuery via
|
|
62
62
|
* the collection processor (in-memory, full records — so `deferred:`
|
|
63
63
|
* field-stripping never causes a false "missing required").
|
|
64
64
|
*
|
|
@@ -85,15 +85,13 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
85
85
|
const config = site.config || {}
|
|
86
86
|
const basePath = typeof config.base === 'string' ? config.base : '/'
|
|
87
87
|
|
|
88
|
-
// Compile file-based
|
|
89
|
-
// pipeline runs). Full records — `
|
|
88
|
+
// Compile file-based byQuery in-memory (the same step the data-only
|
|
89
|
+
// pipeline runs). Full records — `writeQueryFiles` is the stage that
|
|
90
90
|
// strips `deferred:` fields, and we skip it.
|
|
91
|
-
let
|
|
92
|
-
if (config.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
: null
|
|
96
|
-
collections = await processCollections(siteRoot, config.collections, collectionsBase, basePath)
|
|
91
|
+
let byQuery = {}
|
|
92
|
+
if (config.queries && typeof config.queries === 'object') {
|
|
93
|
+
|
|
94
|
+
byQuery = await processQueries(siteRoot, config.queries, config.paths?.entities, basePath)
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
// Pass 1 — discover unique (file, schema-ref) pairs and who uses each.
|
|
@@ -144,7 +142,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
144
142
|
let recordCount = 0
|
|
145
143
|
|
|
146
144
|
for (const entry of work.values()) {
|
|
147
|
-
const { records, error } = await resolveRecords(entry.path, {
|
|
145
|
+
const { records, error } = await resolveRecords(entry.path, { byQuery, siteRoot })
|
|
148
146
|
if (error) {
|
|
149
147
|
setupErrors.push({ file: entry.path, message: error, users: entry.users })
|
|
150
148
|
continue
|
|
@@ -252,7 +250,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
252
250
|
* works on a link-mode site whose foundation is a registry ref with nothing
|
|
253
251
|
* local), and an unresolved tag must be silent rather than an error. So the
|
|
254
252
|
* package is resolved from this build's own graph, where it is an
|
|
255
|
-
* optionalDependency, exactly as `i18n/
|
|
253
|
+
* optionalDependency, exactly as `i18n/records.js` resolves it.
|
|
256
254
|
*
|
|
257
255
|
* @param {Object} site - collected site content (`{ pages }`)
|
|
258
256
|
* @returns {Promise<{ violations: Array, schemas: Set<string>, checked: number }>}
|
|
@@ -337,7 +335,7 @@ function nodesOfType(doc, type) {
|
|
|
337
335
|
* This is the pass that closes an odd hole: a component declares
|
|
338
336
|
* `data: { form: '@std/form' }`, an author writes a ```` ```yaml:form ```` block,
|
|
339
337
|
* and until now **nothing checked one against the other**. The join walked
|
|
340
|
-
* `section.fetch` —
|
|
338
|
+
* `section.fetch` — byQuery and fetches — so a schema bound to a key that a
|
|
341
339
|
* tagged block fills was never applied to anything. `@std/form` existed for
|
|
342
340
|
* exactly this and had never run outside its own contract test.
|
|
343
341
|
*
|
|
@@ -462,17 +460,17 @@ function walkSections(sections, visit) {
|
|
|
462
460
|
}
|
|
463
461
|
|
|
464
462
|
/**
|
|
465
|
-
* Resolve a fetch `path` to its records. Declared
|
|
463
|
+
* Resolve a fetch `path` to its records. Declared byQuery come from the
|
|
466
464
|
* in-memory compile (full records, current); a bare file under `public/`
|
|
467
465
|
* (hand-authored data) is read from disk. Either way no prior build is needed.
|
|
468
466
|
*/
|
|
469
|
-
async function resolveRecords(path, {
|
|
467
|
+
async function resolveRecords(path, { byQuery, siteRoot }) {
|
|
470
468
|
// A compiled-collection URL → a declared collection? Use the compiled
|
|
471
469
|
// records. Anything else falls through to the file read below.
|
|
472
|
-
const name =
|
|
470
|
+
const name = queryNameFromUrl(path)
|
|
473
471
|
let records
|
|
474
|
-
if (Object.prototype.hasOwnProperty.call(
|
|
475
|
-
records =
|
|
472
|
+
if (Object.prototype.hasOwnProperty.call(byQuery, name)) {
|
|
473
|
+
records = byQuery[name]
|
|
476
474
|
} else {
|
|
477
475
|
// Otherwise read the file from public/ (the data-fetcher's resolution root).
|
|
478
476
|
const filePath = join(siteRoot, 'public', path)
|