@uniweb/build 0.30.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,15 +59,15 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "^0.1.15",
63
- "@uniweb/semantic-parser": "^1.3.1",
64
62
  "@uniweb/content-reader": "^1.2.4",
65
- "@uniweb/content-writer": "^0.3.4",
66
63
  "@uniweb/projections": "^0.5.2",
67
- "@uniweb/schemas": "^0.2.11"
64
+ "@uniweb/semantic-parser": "^1.3.1",
65
+ "@uniweb/schemas": "^0.2.11",
66
+ "@uniweb/content-writer": "^0.3.4",
67
+ "@uniweb/theming": "^0.1.15"
68
68
  },
69
69
  "optionalDependencies": {
70
- "@uniweb/runtime": "^0.13.2"
70
+ "@uniweb/runtime": "^0.13.3"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -76,7 +76,7 @@
76
76
  "@tailwindcss/vite": "^4.0.0",
77
77
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
78
78
  "vite-plugin-svgr": "^4.0.0",
79
- "@uniweb/core": "^0.14.0"
79
+ "@uniweb/core": "^0.14.1"
80
80
  },
81
81
  "peerDependenciesMeta": {
82
82
  "vite": {
@@ -2396,6 +2396,16 @@ export async function collectSiteContent(sitePath, options = {}) {
2396
2396
  // consumer and never ships in a payload (the visitor runtime is
2397
2397
  // list-unaware; the sync lane reads site.yml directly, not this output).
2398
2398
  const { publishLanguages: _publishLanguages, ...runtimeSiteConfig } = siteConfig
2399
+ // ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
2400
+ // `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
2401
+ // that a visitor can fetch. They have no runtime reader (nothing in core, runtime
2402
+ // or kit reads a `config.$*` key), so this removes noise for four of them and a
2403
+ // real disclosure for the fifth: `$secrets` carries no values, but its entries
2404
+ // name every secret the site has, and an inventory of credential names is not
2405
+ // something an `export` should publish.
2406
+ for (const key of Object.keys(runtimeSiteConfig)) {
2407
+ if (key.startsWith('$')) delete runtimeSiteConfig[key]
2408
+ }
2399
2409
 
2400
2410
  return {
2401
2411
  config: {
package/src/uwx/folder.js CHANGED
@@ -187,10 +187,17 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
187
187
 
188
188
  const missing = []
189
189
  const contents = contentsFromNodes(folderNodes, byEntityId, missing)
190
+ // ⚠️ `id` IS THE ENTITY'S POOL PATH, NOT A FOLDER PATH — say so, because the two
191
+ // read identically and a reader who takes it for a placement concludes the
192
+ // emitter is dropping a branch it never had. *(Measured 2026-08-31: the backend
193
+ // lane read `folder: "articles/outdoor-hygge"` as a placement under an
194
+ // `articles` branch and opened a channel about a missing branch node; the string
195
+ // was naming `entities/articles/outdoor-hygge.md`.)*
190
196
  const warnings = missing.map(
191
197
  (id) =>
192
- `folder: "${id}" is placed in records.yml but produced no record entity — ` +
193
- `the placement was dropped rather than sent pointing at nothing.`
198
+ `records.yml: "${id}" a path under entities/ — is listed, but no record ` +
199
+ `entity was produced for it (check that its schema resolves). The placement ` +
200
+ `was dropped rather than sent pointing at nothing.`
194
201
  )
195
202
 
196
203
  const document = {
@@ -153,7 +153,6 @@ const INFO_TO_SITE_YML = {
153
153
  data: 'data',
154
154
  template: 'template',
155
155
  seo: 'seo',
156
- app: 'app', // deployment's @uniweb/app-spec ref (bare uuid; deployment-local)
157
156
  }
158
157
 
159
158
  /**
@@ -208,6 +207,32 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
208
207
  : []
209
208
  if (extensions.length > 0) siteChanges.extensions = extensions
210
209
 
210
+ // services[] → site.yml::$services · secrets[] → site.yml::$secrets.
211
+ //
212
+ // ⭐ The `$` prefix, and not the bare name, for the reason spelled out in
213
+ // `uwx/site.js`: `site.yml::services` already means "pretend a host offers these"
214
+ // on the bundle lane, and one key cannot mean two things.
215
+ //
216
+ // ⭐ `$id` is DROPPED — it is derived (a service's `name`; a secret's
217
+ // `service:name` pair), so writing it back would put a redundant handle in the
218
+ // author's file and invite them to edit the one field that must not drift from
219
+ // the fields it is derived from. Same call as `extensions` above.
220
+ //
221
+ // ⛔ Everything else rides VERBATIM, `config` included: it is opaque, per-service
222
+ // and will grow, so projecting a known subset would quietly drop whatever the
223
+ // service gained since this line was written — and the next push would then send
224
+ // the truncated version back as authoritative.
225
+ //
226
+ // ⚠️ An EMPTY section is written as an empty list, not skipped. `[]` is a real
227
+ // state — "this site has no service rows" — and it is the state a `pull` must be
228
+ // able to deliver after the last one was removed. Skipping would leave a stale
229
+ // `$services` on disk that the next push would resurrect.
230
+ for (const [section, ymlKey] of [['services', '$services'], ['secrets', '$secrets']]) {
231
+ const records = document?.[section]
232
+ if (!Array.isArray(records)) continue
233
+ siteChanges[ymlKey] = records.map(({ $id: _id, ...fields }) => fields)
234
+ }
235
+
211
236
  const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
212
237
 
213
238
  // theme (whole object) → theme.yml.
package/src/uwx/site.js CHANGED
@@ -809,6 +809,121 @@ function queriesNested(declarations, uuids = null) {
809
809
  return out
810
810
  }
811
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
+
812
927
  /**
813
928
  * Map a file site project to the nested `@uniweb/site-content` `$`-document
814
929
  * (see the lane header above). PURE — reads the project, never mints, never writes.
@@ -957,13 +1072,38 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
957
1072
  // secret store is the only right home either way.
958
1073
 
959
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).
960
1094
  setIf(info, 'paths', siteYml.paths)
961
1095
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
962
- // `app` — the deployment's `@uniweb/app-spec` reference (a bare uuid string),
963
- // bound when the site is hosted as a composite. Authored config that round-trips
964
- // verbatim/opaque (like `foundation`), so a pull→edit→push preserves it; it is
965
- // deployment-LOCAL (not portable across deployments). (uwx-format.md → info.app.)
966
- setIf(info, 'app', siteYml.app)
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.)
967
1107
  // `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
968
1108
  // backend applies a clonability designation to this site-content entity (it is
969
1109
  // NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
@@ -1017,6 +1157,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1017
1157
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1018
1158
  // consumer sees, leave the identifier alone.
1019
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
1020
1167
  return doc
1021
1168
  }
1022
1169