@uniweb/build 0.43.0 → 0.43.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.43.0",
3
+ "version": "0.43.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,14 +57,14 @@
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
59
  "@uniweb/content-reader": "^1.2.4",
60
- "@uniweb/content-writer": "^0.3.4",
61
- "@uniweb/projections": "^0.5.13",
62
- "@uniweb/semantic-parser": "^1.4.0",
63
60
  "@uniweb/schemas": "^0.2.13",
64
- "@uniweb/theming": "^0.1.15"
61
+ "@uniweb/theming": "^0.1.15",
62
+ "@uniweb/projections": "^0.5.13",
63
+ "@uniweb/content-writer": "^0.3.4",
64
+ "@uniweb/semantic-parser": "^1.4.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "^0.19.0"
67
+ "@uniweb/runtime": "^0.19.3"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -73,7 +73,7 @@
73
73
  "@tailwindcss/vite": "^4.0.0",
74
74
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
75
75
  "vite-plugin-svgr": "^4.0.0",
76
- "@uniweb/core": "^0.24.1"
76
+ "@uniweb/core": "^0.24.2"
77
77
  },
78
78
  "peerDependenciesMeta": {
79
79
  "vite": {
package/src/schema.js CHANGED
@@ -758,13 +758,26 @@ function reportSupports(srcDir, authored, derived, emitted) {
758
758
  }
759
759
  }
760
760
 
761
- if (derived.blind && (emitted === undefined || emitted.length === 0)) {
762
- // The one case where an empty result is NOT a proven "none": something
763
- // named a service in a way the AST could not read, so the set is short by
764
- // an unknown amount and absent/UNKNOWN is the honest wire value.
761
+ // WARN WHENEVER THE DERIVATION WAS BLIND not only when it came back empty.
762
+ //
763
+ // This used to require an empty result, which left the more likely case
764
+ // silent: a foundation that reaches `submit` through a literal and `booking`
765
+ // through a computed name publishes `["submit"]` — short, with no warning, and
766
+ // the one person who could fix it never hears about it.
767
+ //
768
+ // ⭐ That is what makes the lower bound tolerable. How often a foundation
769
+ // computes a service name is unknowable from here — foundations are
770
+ // third-party — but it does not need to be known, because the build detects
771
+ // its own blindness and can say so to the developer at the moment they build.
772
+ if (derived.blind) {
773
+ const empty = emitted === undefined || emitted.length === 0
765
774
  console.warn(
766
- `Warning: a service is resolved by a computed name, so \`uniweb.supports\` cannot be ` +
767
- `derived and is left undeclared. List it in package.json to publish it.`,
775
+ empty
776
+ ? `Warning: a service is resolved by a computed name, so \`uniweb.supports\` cannot be ` +
777
+ `derived and is left undeclared. List it in package.json to publish it.`
778
+ : `Warning: a service is resolved by a computed name, so the derived ` +
779
+ `\`uniweb.supports\` may be incomplete. Add any service missing from ` +
780
+ `[${(emitted || []).join(', ')}] to package.json.`,
768
781
  )
769
782
  for (const at of derived.blindAt || []) console.warn(` at ${at}`)
770
783
  }
@@ -2095,11 +2095,19 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2095
2095
  /**
2096
2096
  * Load foundation schema data needed by the content collector.
2097
2097
  *
2098
+ * `hasContentHandler` reports whether the foundation declares `handlers.content`
2099
+ * — the hook that resolves `{…}` in page content. It is NOT used to decide
2100
+ * anything about the build; its only consumer is the `placeholders:` warning at
2101
+ * the call site, which needs to know whether a declared value has any reader.
2102
+ * ⚠️ It cannot tell WHICH engine the handler uses (a handler is a function, and
2103
+ * the build never calls it), so it answers "something could resolve this",
2104
+ * never "Loom will".
2105
+ *
2098
2106
  * @param {string} foundationPath - Path to foundation directory
2099
- * @returns {Promise<{ vars: Object, layoutNames: Set<string> }>}
2107
+ * @returns {Promise<{ vars: Object, layoutNames: Set<string>, hasContentHandler: boolean }>}
2100
2108
  */
2101
2109
  export async function loadFoundationInfo(foundationPath) {
2102
- if (!foundationPath) return { vars: {}, layoutNames: new Set() }
2110
+ if (!foundationPath) return { vars: {}, layoutNames: new Set(), hasContentHandler: false }
2103
2111
 
2104
2112
  // ⛔ **NOT `dist/meta/schema.json`.** That file is the EDITOR's artifact — the
2105
2113
  // rich per-section declaration a visual editor needs to render parameter forms
@@ -2133,9 +2141,13 @@ export async function loadFoundationInfo(foundationPath) {
2133
2141
  // Two independent reads, so a failure in one does not cost the other. The
2134
2142
  // previous single try/catch lost the layouts when only the config was broken.
2135
2143
  let vars = {}
2144
+ let hasContentHandler = false
2136
2145
  try {
2137
2146
  const config = await loadFoundationConfig(srcDir)
2138
2147
  vars = config?.vars || {}
2148
+ // `loadFoundationConfig` spreads the module's default export, so `handlers`
2149
+ // arrives intact even though it holds functions and never reaches schema.json.
2150
+ hasContentHandler = typeof config?.handlers?.content === 'function'
2139
2151
  } catch (err) {
2140
2152
  console.warn(
2141
2153
  `[content-collector] Could not read the foundation's declared theme vars from ${srcDir}: ${err.message}\n` +
@@ -2154,7 +2166,7 @@ export async function loadFoundationInfo(foundationPath) {
2154
2166
  )
2155
2167
  }
2156
2168
 
2157
- return { vars, layoutNames }
2169
+ return { vars, layoutNames, hasContentHandler }
2158
2170
  }
2159
2171
 
2160
2172
  /**
@@ -2372,7 +2384,27 @@ export async function collectSiteContent(sitePath, options = {}) {
2372
2384
  const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
2373
2385
 
2374
2386
  // Load foundation info (vars + layout names) and process theme
2375
- const { vars: foundationVars, layoutNames: layoutNames } = await loadFoundationInfo(foundationPath)
2387
+ const { vars: foundationVars, layoutNames: layoutNames, hasContentHandler } =
2388
+ await loadFoundationInfo(foundationPath)
2389
+
2390
+ // ⭐ `placeholders:` IS DECLARED FOR A READER THAT MAY NOT EXIST, and that is
2391
+ // the one way this feature fails. Resolving `{…}` in page content is a
2392
+ // FOUNDATION capability (`handlers.content`, normally @uniweb/loom), not
2393
+ // something the framework does for every site — so on a foundation that
2394
+ // declares no content handler the block is inert and the page renders the
2395
+ // literal `{vendor.email}`. That reads as an authoring typo, which is why it
2396
+ // is worth a build-time line rather than leaving the author to find it.
2397
+ //
2398
+ // ⚖️ A WARNING, never an error: the site may be mid-migration, or the author
2399
+ // may be about to switch foundations, and a declared-but-unread value harms
2400
+ // nothing. Same rule as the retired-`fetcher:` keys above — warn once, carry on.
2401
+ if (siteConfig.placeholders && !hasContentHandler) {
2402
+ console.warn(
2403
+ `[uniweb] site.yml declares \`placeholders:\` but the foundation has no \`handlers.content\`, ` +
2404
+ `so nothing will resolve them — pages will render the literal \`{name}\` text.\n` +
2405
+ `[uniweb] A foundation opts in with \`handlers: createLoomHandlers({ vars })\` from @uniweb/loom.`
2406
+ )
2407
+ }
2376
2408
  // `base` reaches the theme because self-hosted font faces are authored
2377
2409
  // root-relative (`/fonts/x.woff2`) and the emitted @font-face lives in an
2378
2410
  // inline <style> — under a subdirectory deployment it must carry the base.
@@ -155,6 +155,23 @@ const INFO_TO_SITE_YML = {
155
155
  seo: 'seo',
156
156
  }
157
157
 
158
+ // ── `config` Section → site.yml ───────────────────────────────────────────────
159
+ //
160
+ // The `config` Section (see `site.js::configNested`) carries authored
161
+ // configuration that does not belong on `info`. Each key maps to a
162
+ // top-level `site.yml` key of the same name, verbatim, so the author's file
163
+ // round-trips unchanged.
164
+ //
165
+ // ⛔ THIS MAP IS THE HALF THAT GETS LEFT OUT. The push side tests green entirely
166
+ // on its own, so a missing entry here is invisible until someone pulls and finds
167
+ // their block gone from site.yml. Every key `configNested` emits needs a line.
168
+ //
169
+ // 📌 `theme` will belong here after the stage-2 move off `info` — it is projected
170
+ // to `theme.yml` (not site.yml) and so will need its own handling, not a row.
171
+ const CONFIG_TO_SITE_YML = {
172
+ placeholders: 'placeholders',
173
+ }
174
+
158
175
  /**
159
176
  * Project a site-content document's `info` (+ `extensions`) onto the site's
160
177
  * config files: `site.yml`, `theme.yml`, and `head.html`. Idempotent; only the
@@ -193,6 +210,14 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
193
210
  if (info[infoKey] !== undefined) siteChanges[ymlKey] = info[infoKey]
194
211
  }
195
212
 
213
+ // The `config` Section — authored configuration that is not identity, so it is
214
+ // not on `info`. Same verbatim treatment as the `info` block above; a Section
215
+ // the document does not carry writes nothing, like every other absent key here.
216
+ const configSection = document?.config || {}
217
+ for (const [configKey, ymlKey] of Object.entries(CONFIG_TO_SITE_YML)) {
218
+ if (configSection[configKey] !== undefined) siteChanges[ymlKey] = configSection[configKey]
219
+ }
220
+
196
221
  // extensions[] → site.yml::extensions. Each entry carries EITHER `ref` (a
197
222
  // catalog ref or a local name — an extension is a foundation and is declared
198
223
  // like one) OR `url`. Project back whichever is present so a ref survives a
package/src/uwx/site.js CHANGED
@@ -955,6 +955,54 @@ function secretsNested(siteYml) {
955
955
  )
956
956
  }
957
957
 
958
+ // ── `config` — the site's authored configuration ──────────────────────────────
959
+ //
960
+ // ⭐ THE LINE IS IDENTITY vs CONFIGURATION. `info` answers *"which site is this?"*
961
+ // — the name/label record, and it is read far more often than it is read in full.
962
+ // `config` answers *"what does this site render with?"*, and it exists because that
963
+ // second question had no home and its answers were accumulating on `info`.
964
+ //
965
+ // A `single` Section: one record, holding each block verbatim under its own key.
966
+ // Verbatim is the point — the authored shape is a nested map and it comes back as
967
+ // one, so nothing has to be flattened on push or rebuilt on pull.
968
+ //
969
+ // ⚠️ AUTHORS NEVER SEE THIS NAME. It is a wire and Model name; `site-project.js`
970
+ // writes `config.placeholders` back out to `site.yml::placeholders`. So it does
971
+ // not have to read well in a YAML file, and it is named flatly for what it holds,
972
+ // like `pages` / `queries` / `records`.
973
+ //
974
+ // ⚠️ AND IT IS A SUBSET OF THE RUNTIME'S `website.config`, not the same thing —
975
+ // that object is all of site.yml spread whole. One word, two scopes: everything
976
+ // in this Section lands in `website.config`, never the reverse. (uwx-format.md →
977
+ // the `config` Section.)
978
+ //
979
+ // 📌 Stage 2, not done here: `info.theme` belongs in this Section by the same
980
+ // argument and is NOT moved, because moving it is a DROP from `info` and a drop
981
+ // refuses (there is no rename detection — uwx-format.md § *A rename refuses*).
982
+ // That is a destructive migration on live data and is priced separately with the
983
+ // lane that pays it. Adding this Section is additive and auto-applies; do not
984
+ // quietly fold `theme` in on the strength of the comment above.
985
+ //
986
+ // ⛔ NEVER EMIT `{}` — and NOT for the reason this comment first gave. It said `{}`
987
+ // reads as "clear the stored record", by analogy with `services` above. Backend
988
+ // corrected it (2026-09-08): on a `single` Section the value must be an object, so
989
+ // `{}` parses as ONE RECORD WITH NO FIELDS, not zero records. There is no `[]`
990
+ // analogue — `multi` can say "zero records", `single` cannot.
991
+ //
992
+ // ⚠️ TODAY THE TWO COINCIDE BY ACCIDENT, because `placeholders` is this Section's
993
+ // only field, so "a record with no fields" and "placeholders cleared" are the same
994
+ // state. They diverge the moment `config` gains a second field, and then `{}` means
995
+ // *clear every field on config* — a far wider statement than the one intended.
996
+ //
997
+ // ⇒ The behaviour below is right either way: emit only when the file declares
998
+ // something. If an explicit clear is ever wanted, ask backend for a real form rather
999
+ // than inferring one from an empty object.
1000
+ function configNested(siteYml) {
1001
+ const config = {}
1002
+ setIf(config, 'placeholders', siteYml.placeholders)
1003
+ return Object.keys(config).length > 0 ? config : undefined
1004
+ }
1005
+
958
1006
  /**
959
1007
  * Map a file site project to the nested `@uniweb/site-content` `$`-document
960
1008
  * (see the lane header above). PURE — reads the project, never mints, never writes.
@@ -1124,6 +1172,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1124
1172
  // The provisioned record rides the `$services` section instead (see servicesNested).
1125
1173
  setIf(info, 'paths', siteYml.paths)
1126
1174
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
1175
+ // ⛔ `placeholders` IS NOT HERE, DELIBERATELY — it rides the `config` Section
1176
+ // (`configNested` below). `info` carries the site's IDENTITY, and every key on
1177
+ // this allowlist is one WE name and the author merely fills. `placeholders` is
1178
+ // the first where the author invents the key set, and it is unbounded — which
1179
+ // puts it on the Section side of the same line `queries` / `records` / `folders`
1180
+ // already sit on. See `configNested` for the split.
1181
+ //
1127
1182
  // ⛔ `app` IS RETIRED — do not reintroduce it, in either direction. It carried an
1128
1183
  // opaque uuid naming a separate entity a host bound to the site; that entity is
1129
1184
  // gone, a site's services belong to the site itself, and NOTHING replaces the key.
@@ -1177,6 +1232,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1177
1232
  doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
1178
1233
  doc.$model = SITE_MODEL_NAME
1179
1234
  doc.info = info
1235
+ // Emitted only when the file declares something — see `configNested`.
1236
+ const config = configNested(siteYml)
1237
+ if (config) doc.config = config
1180
1238
  doc.pages = pages
1181
1239
  doc.layout_sections = layoutSections
1182
1240
  doc.extensions = extensionsNested(siteYml)
@@ -1191,9 +1249,26 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1191
1249
  // Emitted ONLY when the file declares the key — see the header above
1192
1250
  // `serviceRecords`: on a replaced Section, absent and empty are different
1193
1251
  // requests and one of them is destructive.
1194
- const services = servicesNested(siteYml)
1252
+ //
1253
+ // ⭐ AND ONLY WHEN THE CALLER SAYS THE DECLARATION IS A REQUEST.
1254
+ // `opts.declareServices === false` withholds both Sections for THIS push, which
1255
+ // is not the same as the file having no key — the file still declares one; the
1256
+ // caller has determined the owner is not asking for anything new by it.
1257
+ //
1258
+ // ⛔ Why this decision cannot live here: the Sections are REPLACED wholesale by
1259
+ // what we send (`SectionScope::DeclaredOnly`), so re-sending an unchanged block
1260
+ // OVERWRITES whatever the stored request has become since — including a decision
1261
+ // the owner made in the app, where the consent workflow's publish happens. But
1262
+ // "has it changed since we last agreed?" needs the last agreed state, which is
1263
+ // project memory (`deploy.yml`) the CLI owns and this pure mapper must not read.
1264
+ // ⇒ The CLI decides; this honours the decision.
1265
+ //
1266
+ // ⚖️ Default is to declare, so every existing caller is unchanged and the
1267
+ // withholding is opt-in.
1268
+ const declare = opts.declareServices !== false
1269
+ const services = declare ? servicesNested(siteYml) : undefined
1195
1270
  if (services) doc.services = services
1196
- const secrets = secretsNested(siteYml)
1271
+ const secrets = declare ? secretsNested(siteYml) : undefined
1197
1272
  if (secrets) doc.secrets = secrets
1198
1273
  return doc
1199
1274
  }
@@ -305,7 +305,12 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
305
305
  const siteDoc = includeSite
306
306
  ? await siteProjectToDocument(siteRoot, {
307
307
  sourceLocale,
308
- ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {})
308
+ ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
309
+ // Withhold the `$services`/`$secrets` Sections when the caller has
310
+ // determined the file is not asking for anything new by them. Passed
311
+ // through rather than decided here: the last-agreed state is project
312
+ // memory the CLI owns. See site.js at `declareServices`.
313
+ ...(opts.declareServices === false ? { declareServices: false } : {})
309
314
  })
310
315
  : null
311
316
  // Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are