@uniweb/build 0.43.1 → 0.44.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.43.1",
3
+ "version": "0.44.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,10 +58,10 @@
58
58
  "yaml": "^2.5.0",
59
59
  "@uniweb/content-reader": "^1.2.4",
60
60
  "@uniweb/schemas": "^0.2.13",
61
- "@uniweb/theming": "^0.1.15",
62
- "@uniweb/projections": "^0.5.13",
61
+ "@uniweb/semantic-parser": "^1.4.0",
63
62
  "@uniweb/content-writer": "^0.3.4",
64
- "@uniweb/semantic-parser": "^1.4.0"
63
+ "@uniweb/theming": "^0.1.15",
64
+ "@uniweb/projections": "^0.5.13"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/runtime": "^0.19.3"
@@ -190,7 +190,7 @@ function detectVersions(folderNames) {
190
190
  * @param {string|Array<string>|undefined} data
191
191
  * @returns {Object|Array<Object>|undefined}
192
192
  */
193
- function fetchFromDataShorthand(data) {
193
+ export function fetchFromDataShorthand(data) {
194
194
  if (!data) return undefined
195
195
  if (Array.isArray(data)) return data.map((query) => ({ query }))
196
196
  return { query: data }
@@ -1164,7 +1164,44 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
1164
1164
  * @param {Object} options.versionContext - Version context from parent { version, versionMeta, scope }
1165
1165
  * @returns {Object} Page data with assets manifest
1166
1166
  */
1167
- async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null, layoutName = null } = {}) {
1167
+ /**
1168
+ * Normalize an authored `layout:` into `{ name?, hide?, params? }`.
1169
+ *
1170
+ * ⭐ The authored form is documented in two shapes — the string shorthand
1171
+ * (`layout: DocsLayout`) and the expanded object (`{ name, hide, params }`,
1172
+ * `docs/reference/page-configuration.md`). Both reduce to the same record here so
1173
+ * every tier can be merged with the same rule.
1174
+ *
1175
+ * ⚠️ Only present keys are set. `hide: []` is a real value (hide nothing,
1176
+ * overriding an ancestor) and must survive; an absent `hide` must not shadow one.
1177
+ */
1178
+ function normalizeLayoutConfig(raw) {
1179
+ if (typeof raw === 'string') return raw ? { name: raw } : {}
1180
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
1181
+ const out = {}
1182
+ if (typeof raw.name === 'string' && raw.name) out.name = raw.name
1183
+ if (Array.isArray(raw.hide)) out.hide = raw.hide
1184
+ if (raw.params && typeof raw.params === 'object') out.params = raw.params
1185
+ return out
1186
+ }
1187
+
1188
+ /**
1189
+ * Merge an inherited layout config with a nearer one — site → folder → page.
1190
+ *
1191
+ * ⭐ PER-FIELD, nearest wins, which is the cascade `seo` already documents:
1192
+ * *"the page wins, the site fills the gaps"*. So a page that sets only `hide`
1193
+ * keeps an ancestor's `name`.
1194
+ *
1195
+ * ⛔ NOT a union on `hide`. A page saying `hide: [footer]` under a site saying
1196
+ * `hide: [right]` hides the footer and shows the right rail — it REPLACES the
1197
+ * ancestor's list. Union would make an ancestor's hide impossible to undo from a
1198
+ * page, which is the one thing an override is for.
1199
+ */
1200
+ function mergeLayoutConfig(inherited, own) {
1201
+ return { ...(inherited || {}), ...(own || {}) }
1202
+ }
1203
+
1204
+ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null, inheritedLayout = null } = {}) {
1168
1205
  const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
1169
1206
 
1170
1207
  // Note: We no longer skip hidden pages here - they still exist as valid pages,
@@ -1455,13 +1492,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1455
1492
  // Extract configuration
1456
1493
  const { seo = {}, layout: layoutConfig, ...restConfig } = pageConfig
1457
1494
 
1458
- // Resolve layout name: page.yml layout (string or object.name) > inherited from parent > null
1459
- const pageLayoutName = typeof layoutConfig === 'string' ? layoutConfig
1460
- : layoutConfig?.name || null
1461
- const resolvedLayoutName = pageLayoutName || layoutName || null
1462
-
1463
- // Layout panel visibility (from object form of layout config)
1464
- const layoutObj = typeof layoutConfig === 'object' && layoutConfig !== null ? layoutConfig : {}
1495
+ // Resolve the effective layout by cascading site folder page, per field.
1496
+ // Until 2026-09-09 only `name` cascaded and `hide` / `params` were read from
1497
+ // the PAGE's own config alone, so the documented expanded form was silently
1498
+ // two-thirds ignored at every tier above the page — including `site.yml`, whose
1499
+ // `layout:` the collector already read for its name.
1500
+ const layoutObj = mergeLayoutConfig(inheritedLayout, normalizeLayoutConfig(layoutConfig))
1501
+ const resolvedLayoutName = layoutObj.name || null
1465
1502
 
1466
1503
  // For dynamic routes, determine the parent's data schema — this tells
1467
1504
  // prerender which data array to iterate over.
@@ -1611,7 +1648,7 @@ function determineIndexPage(orderConfig, availableFolders) {
1611
1648
  * @param {string} contentMode - 'sections' (default) or 'pages' (md files are child pages)
1612
1649
  * @returns {Promise<Object>} { pages, assetCollection, iconCollection, notFound, versionedScopes }
1613
1650
  */
1614
- async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections', mounts = null, parentLayoutName = null) {
1651
+ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections', mounts = null, parentLayout = null) {
1615
1652
  const entries = await readdir(dirPath)
1616
1653
  const pages = []
1617
1654
  let assetCollection = {
@@ -1667,8 +1704,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1667
1704
  ? local.mode
1668
1705
  : mounted?.mode ?? local?.mode ?? contentMode
1669
1706
 
1670
- const folderLayout = typeof dirConfig.layout === 'string' ? dirConfig.layout
1671
- : dirConfig.layout?.name || null
1707
+ const folderLayout = normalizeLayoutConfig(dirConfig.layout)
1672
1708
 
1673
1709
  pageFolders.push({
1674
1710
  name,
@@ -1685,7 +1721,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1685
1721
  pages: dirConfig.pages,
1686
1722
  index: dirConfig.index
1687
1723
  },
1688
- childLayoutName: folderLayout
1724
+ childLayout: folderLayout
1689
1725
  })
1690
1726
  }
1691
1727
 
@@ -1724,7 +1760,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1724
1760
  versionedScopes.set(parentRoute, versionMeta)
1725
1761
 
1726
1762
  for (const folder of orderedFolders) {
1727
- const { name: entry, path: entryPath, childOrderConfig, childLayoutName } = folder
1763
+ const { name: entry, path: entryPath, childOrderConfig, childLayout } = folder
1728
1764
 
1729
1765
  if (isVersionFolder(entry)) {
1730
1766
  const versionInfo = versionMeta.versions.find(v => v.id === entry)
@@ -1738,7 +1774,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1738
1774
  const subResult = await collectPagesRecursive(
1739
1775
  entryPath, versionRoute, siteRoot, childOrderConfig, parentFetch,
1740
1776
  { version: versionInfo, versionMeta, scope: parentRoute },
1741
- 'sections', null, childLayoutName || parentLayoutName
1777
+ 'sections', null, mergeLayoutConfig(parentLayout, childLayout)
1742
1778
  )
1743
1779
 
1744
1780
  pages.push(...subResult.pages)
@@ -1750,7 +1786,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1750
1786
  } else {
1751
1787
  const result = await processPage(entryPath, entry, siteRoot, {
1752
1788
  isIndex: false, parentRoute, parentFetch,
1753
- layoutName: childLayoutName || parentLayoutName
1789
+ inheritedLayout: mergeLayoutConfig(parentLayout, childLayout)
1754
1790
  })
1755
1791
  if (result) {
1756
1792
  pages.push(result.page)
@@ -1818,25 +1854,32 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1818
1854
  page.route = parentRoute
1819
1855
  }
1820
1856
 
1821
- // Inherit layout name from parent (folder.yml or site.yml cascade)
1822
- if (parentLayoutName && !page.layout.name) {
1823
- page.layout.name = parentLayoutName
1824
- }
1857
+ // Inherit the layout from the folder / site cascade.
1858
+ //
1859
+ // ⭐ THE WHOLE OBJECT, not just the name. `processFileAsPage` (folder-mode
1860
+ // `.md`-as-pages) takes no layout argument, so this post-hoc merge is where
1861
+ // the cascade reaches these pages at all — the threaded path above covers
1862
+ // every other kind. Until 2026-09-09 it patched `name` only, which is why a
1863
+ // folder or site `hide:` never reached a folder-mode page.
1864
+ //
1865
+ // `normalizeLayoutConfig` first so the page's own record has no undefined
1866
+ // keys to shadow an inherited value through the spread.
1867
+ page.layout = mergeLayoutConfig(parentLayout, normalizeLayoutConfig(page.layout))
1825
1868
 
1826
1869
  pages.push(page)
1827
1870
  }
1828
1871
 
1829
1872
  // Process subdirectories
1830
1873
  for (const folder of orderedFolders) {
1831
- const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayoutName } = folder
1874
+ const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
1832
1875
  const isIndex = entry === indexName
1833
- const effectiveLayout = childLayoutName || parentLayoutName
1876
+ const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
1834
1877
 
1835
1878
  if (dirMode === 'sections') {
1836
1879
  // Subdirectory overrides to page mode — process normally
1837
1880
  const result = await processPage(entryPath, entry, siteRoot, {
1838
1881
  isIndex, parentRoute, parentFetch, versionContext,
1839
- layoutName: effectiveLayout
1882
+ inheritedLayout: effectiveLayout
1840
1883
  })
1841
1884
 
1842
1885
  if (result) {
@@ -1875,7 +1918,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1875
1918
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
1876
1919
 
1877
1920
  // Resolve layout for container page
1878
- const containerLayoutObj = typeof dirConfig.layout === 'object' && dirConfig.layout !== null ? dirConfig.layout : {}
1879
1921
 
1880
1922
  const containerPage = {
1881
1923
  route: containerRoute,
@@ -1895,11 +1937,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1895
1937
  hidden: dirConfig.hidden || false,
1896
1938
  hideIn: normalizeHideIn(dirConfig),
1897
1939
  ...(dirConfig.knowledge != null ? { knowledge: dirConfig.knowledge } : {}),
1898
- layout: {
1899
- ...(effectiveLayout ? { name: effectiveLayout } : {}),
1900
- ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
1901
- ...(containerLayoutObj.params ? { params: containerLayoutObj.params } : {}),
1902
- },
1940
+ // `effectiveLayout` is ALREADY this container's resolved layout
1941
+ // `mergeLayoutConfig(parentLayout, normalizeLayoutConfig(dirConfig.layout))`
1942
+ // so re-reading `dirConfig.layout` here would drop the inherited half.
1943
+ layout: effectiveLayout,
1903
1944
  seo: {
1904
1945
  noindex: dirConfig.seo?.noindex || false,
1905
1946
  image: dirConfig.seo?.image || null,
@@ -1959,9 +2000,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1959
2000
 
1960
2001
  // Second pass: process each page folder
1961
2002
  for (const folder of orderedFolders) {
1962
- const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayoutName } = folder
2003
+ const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
1963
2004
  const isIndex = entry === indexPageName
1964
- const effectiveLayout = childLayoutName || parentLayoutName
2005
+ const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
1965
2006
 
1966
2007
  if (dirMode === 'pages') {
1967
2008
  // Child directory switches to folder mode (has folder.yml) —
@@ -1971,7 +2012,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1971
2012
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
1972
2013
 
1973
2014
  // Resolve layout for container page
1974
- const containerLayoutObj = typeof dirConfig.layout === 'object' && dirConfig.layout !== null ? dirConfig.layout : {}
1975
2015
 
1976
2016
  const containerPage = {
1977
2017
  route: containerRoute,
@@ -1991,11 +2031,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1991
2031
  hidden: dirConfig.hidden || false,
1992
2032
  hideIn: normalizeHideIn(dirConfig),
1993
2033
  ...(dirConfig.knowledge != null ? { knowledge: dirConfig.knowledge } : {}),
1994
- layout: {
1995
- ...(effectiveLayout ? { name: effectiveLayout } : {}),
1996
- ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
1997
- ...(containerLayoutObj.params ? { params: containerLayoutObj.params } : {}),
1998
- },
2034
+ // Already resolved — see the note on the sibling container above.
2035
+ layout: effectiveLayout,
1999
2036
  seo: {
2000
2037
  noindex: dirConfig.seo?.noindex || false,
2001
2038
  image: dirConfig.seo?.image || null,
@@ -2026,7 +2063,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2026
2063
  // Sections mode — process directory as a page (existing behavior)
2027
2064
  const result = await processPage(entryPath, entry, siteRoot, {
2028
2065
  isIndex, parentRoute, parentFetch, versionContext,
2029
- layoutName: effectiveLayout
2066
+ inheritedLayout: effectiveLayout
2030
2067
  })
2031
2068
 
2032
2069
  if (result) {
@@ -2448,13 +2485,13 @@ export async function collectSiteContent(sitePath, options = {}) {
2448
2485
  // Collect layout areas from layout/ directory (including named layout subdirectories)
2449
2486
  const { layouts } = await collectLayouts(layoutPath, sitePath, layoutNames)
2450
2487
 
2451
- // Site-level layout name (from site.yml layout: field)
2452
- const siteLayoutName = typeof siteConfig.layout === 'string' ? siteConfig.layout
2453
- : siteConfig.layout?.name || null
2488
+ // Site-level layout (from `site.yml::layout`) — the ROOT of the cascade, and
2489
+ // the whole object, not just its name.
2490
+ const siteLayout = normalizeLayoutConfig(siteConfig.layout)
2454
2491
 
2455
2492
  // Recursively collect all pages
2456
2493
  let { pages, assetCollection, iconCollection, notFound, versionedScopes } =
2457
- await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayoutName)
2494
+ await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayout)
2458
2495
 
2459
2496
  // Merge top-level config assets (e.g. document.yml's book.covers.front,
2460
2497
  // banner images, logos) into the manifest. The compile pipeline reads
@@ -2643,7 +2680,13 @@ export async function collectSiteContent(sitePath, options = {}) {
2643
2680
  ...(publishFilterActive && Array.isArray(siteConfig.languages)
2644
2681
  ? { languages: publishable }
2645
2682
  : {}),
2646
- fetch: parseFetchConfig(siteConfig.fetch),
2683
+ // ⛔ `data:` IS THE SHORTHAND FOR `fetch:` AND BOTH LANES MUST READ IT.
2684
+ // This read `siteConfig.fetch` alone until 2026-09-09, so a site-level
2685
+ // `data: articles` reached a backend on the sync lane and was silently
2686
+ // ignored on a static build — the works-on-one-lane shape. The page level
2687
+ // has always used this helper (`pageConfig.fetch || fetchFromDataShorthand(…)`);
2688
+ // the site level simply never did.
2689
+ fetch: parseFetchConfig(siteConfig.fetch || fetchFromDataShorthand(siteConfig.data)),
2647
2690
  fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
2648
2691
  // NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
2649
2692
  // Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
@@ -108,22 +108,13 @@ const INFO_TO_SITE_YML = {
108
108
  // shape it is writing into — and it needs the build's own resolver to know, which is
109
109
  // why that check is not made here (it would drag the vite chain into `uwx/`).
110
110
  foundation: 'foundation',
111
- languages: 'languages',
112
- default_language: 'defaultLanguage',
113
111
  // Publish intent — verbatim both ways, dangling codes included (they carry
114
112
  // the preserved publish intent of a temporarily-undeclared language).
115
- publish_languages: 'publishLanguages',
116
- base: 'base',
117
113
  favicon: 'favicon',
118
- fetcher: 'fetcher',
119
- build: 'build',
120
- search: 'search',
121
114
  // Safe to project back because nothing STAMPS it: `submit` is authored-only,
122
115
  // so a pull can never launder a deploy-derived value into authored config the
123
116
  // way a key carried by both would. A host-supplied destination is resolved at
124
117
  // render time and never enters `info`.
125
- submit: 'submit',
126
- agents: 'agents',
127
118
  // Authored-only, like `submit` above — a host's assistant endpoint is offered
128
119
  // through `config.services` and resolved at render time, so it never enters
129
120
  // `info` and a pull cannot launder it into authored config.
@@ -144,18 +135,23 @@ const INFO_TO_SITE_YML = {
144
135
  // typed, and the push already warned them. The security property is upheld at
145
136
  // the push, not by the pull. Measured end-to-end against a live uniwebd —
146
137
  // every unit test passed before and after, so only a real push touched it.
147
- assistant: 'assistant',
148
138
  // Authored-only, like `submit` and `assistant`: a host's tracking endpoint is
149
139
  // offered through `config.services.tracking` and resolved at render, so it
150
140
  // never enters `info` and a pull cannot launder it into authored config.
151
- tracking: 'tracking',
152
- paths: 'paths',
153
- data: 'data',
141
+ // ⛔ `data` IS NOT VERBATIM — see the explicit branch below. It projects to
142
+ // `site.yml::fetch`, not `site.yml::data`.
154
143
  template: 'template',
155
- seo: 'seo',
144
+ // ⭐ `tags` — authored, non-localized tokens; the filter facet for a list of site
145
+ // cards. Round-trips verbatim like any authored list.
146
+ tags: 'tags',
147
+ // ⛔ `url` and `preview_image` ARE DELIBERATELY ABSENT and must stay absent. Both are
148
+ // BACKEND-STAMPED — a site's live address and its card image URL are assigned by
149
+ // the host — so writing either into `site.yml` would launder a deploy-derived value
150
+ // into authored config, which is the hazard `submit` / `assistant` / `tracking` are
151
+ // annotated against above. Framework emits neither and must project neither.
156
152
  }
157
153
 
158
- // ── `config` Section → site.yml ───────────────────────────────────────────────
154
+ // ── `settings` Section → site.yml ─────────────────────────────────────────────
159
155
  //
160
156
  // The `config` Section (see `site.js::configNested`) carries authored
161
157
  // configuration that does not belong on `info`. Each key maps to a
@@ -168,10 +164,33 @@ const INFO_TO_SITE_YML = {
168
164
  //
169
165
  // 📌 `theme` will belong here after the stage-2 move off `info` — it is projected
170
166
  // to `theme.yml` (not site.yml) and so will need its own handling, not a row.
171
- const CONFIG_TO_SITE_YML = {
167
+ const SETTINGS_TO_SITE_YML = {
172
168
  placeholders: 'placeholders',
169
+ // The locale keys — moved, reverted and moved again on 2026-09-09; see `site.js`
170
+ // for why the middle step happened.
171
+ languages: 'languages',
172
+ default_language: 'defaultLanguage',
173
+ // Publish intent — verbatim both ways, dangling codes included (they carry the
174
+ // preserved publish intent of a temporarily-undeclared language).
175
+ publish_languages: 'publishLanguages',
176
+ base: 'base',
177
+ fetcher: 'fetcher',
178
+ build: 'build',
179
+ paths: 'paths',
180
+ seo: 'seo',
181
+ layout: 'layout',
182
+ // Authored-only service declarations: nothing STAMPS them, so a pull cannot
183
+ // launder a host-supplied endpoint into authored config. A host's own address is
184
+ // offered through `config.services` and resolved at render, never entering the
185
+ // stored record.
186
+ search: 'search',
187
+ submit: 'submit',
188
+ assistant: 'assistant',
189
+ tracking: 'tracking',
190
+ agents: 'agents',
173
191
  }
174
192
 
193
+
175
194
  /**
176
195
  * Project a site-content document's `info` (+ `extensions`) onto the site's
177
196
  * config files: `site.yml`, `theme.yml`, and `head.html`. Idempotent; only the
@@ -187,6 +206,7 @@ const CONFIG_TO_SITE_YML = {
187
206
  */
188
207
  export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
189
208
  const info = document?.info || {}
209
+ const settingsSection = document?.settings || {}
190
210
 
191
211
  const siteChanges = {}
192
212
  // Localized text fields → unwrapped to the source locale (the target locales are
@@ -200,9 +220,9 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
200
220
 
201
221
  // `keywords` is a localized list (mirrors page keywords) → unwrap to the
202
222
  // source locale; the target locales are captured into the locales/ collector.
203
- if (Array.isArray(info.keywords)) info.keywords.forEach((kw) => collector?.add(kw))
204
- const keywords = unwrapLocalizedList(info.keywords, sourceLocale)
205
- if (keywords !== undefined) siteChanges.keywords = keywords
223
+ // (`keywords` moved to the `settings` Section on 2026-09-09 — it renders into
224
+ // `<meta name="keywords">`, so it is seo by function. Handled with the rest of
225
+ // that Section below.)
206
226
 
207
227
  // Verbatim fields (includes `seo` — the site-level social/SEO block).
208
228
  for (const [infoKey, ymlKey] of Object.entries(INFO_TO_SITE_YML)) {
@@ -210,14 +230,37 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
210
230
  if (info[infoKey] !== undefined) siteChanges[ymlKey] = info[infoKey]
211
231
  }
212
232
 
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
233
+ // `settings.fetch` `site.yml::fetch`.
234
+ //
235
+ // `data:` is the authoring SHORTHAND for `fetch:` and the wire carries the
236
+ // desugared form, so `fetch:` is the key that describes what came back. The page
237
+ // lane has always projected this way (`y.fetch = authorableFetch(record.fetch)`);
238
+ // the site lane wrote `data:` verbatim, so an author who typed `fetch:` pushed,
239
+ // pulled, and got a `data:` block back — the value survived and the authored key
240
+ // did not, which the round-trip law forbids (uwx-format.md).
241
+ //
242
+ // ⛔ The producer always desugars, so the wire carries a config or a list of them —
243
+ // never a bare string. Nothing here accommodates an older shape.
244
+ const wireFetch = settingsSection.fetch
245
+ if (wireFetch !== undefined) {
246
+ siteChanges.fetch = Array.isArray(wireFetch)
247
+ ? wireFetch.map((f) => authorableFetch(f))
248
+ : authorableFetch(wireFetch)
249
+ }
250
+
251
+ // The `settings` Section — authored configuration that is not identity, so it is
252
+ // not on the brief. Same verbatim treatment as the `info` block above; a Section
215
253
  // 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]
254
+ for (const [settingsKey, ymlKey] of Object.entries(SETTINGS_TO_SITE_YML)) {
255
+ if (settingsSection[settingsKey] !== undefined) siteChanges[ymlKey] = settingsSection[settingsKey]
219
256
  }
220
257
 
258
+ // `settings.keywords` is a LOCALIZED list (it renders into `<meta name="keywords">`)
259
+ // → unwrap to the source locale; the target locales go to the collector.
260
+ if (Array.isArray(settingsSection.keywords)) settingsSection.keywords.forEach((kw) => collector?.add(kw))
261
+ const settingsKeywords = unwrapLocalizedList(settingsSection.keywords, sourceLocale)
262
+ if (settingsKeywords !== undefined) siteChanges.keywords = settingsKeywords
263
+
221
264
  // extensions[] → site.yml::extensions. Each entry carries EITHER `ref` (a
222
265
  // catalog ref or a local name — an extension is a foundation and is declared
223
266
  // like one) OR `url`. Project back whichever is present so a ref survives a
@@ -261,18 +304,22 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
261
304
  const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
262
305
 
263
306
  // theme (whole object) → theme.yml.
264
- if (info.theme && typeof info.theme === 'object') {
265
- result.theme = writeThemeFile(siteRoot, info.theme)
307
+ if (settingsSection.theme && typeof settingsSection.theme === 'object') {
308
+ result.theme = writeThemeFile(siteRoot, settingsSection.theme)
266
309
  }
267
310
 
268
311
  // head_html → head.html (a raw file, not YAML).
269
- if (info.head_html != null) {
270
- result.headHtml = writeIfChanged(join(siteRoot, 'head.html'), info.head_html)
312
+ if (settingsSection.head_html != null) {
313
+ result.headHtml = writeIfChanged(join(siteRoot, 'head.html'), settingsSection.head_html)
271
314
  }
272
315
 
273
316
  // `info.favicon` rides the verbatim INFO_TO_SITE_YML map above (→ site.yml).
274
- // `info.assets` is intentionally NOT projected: it is a build-derived upload
275
- // manifest, not authored config, so a pull never writes it back to the site.
317
+ // `info.assets` WAS DELETED FROM THE MODEL (2026-09-09), along with `app`,
318
+ // `data_bundle` and `foundation_schema`. It had been a build-derived upload
319
+ // manifest that a pull deliberately never wrote back; framework never populated
320
+ // it. Nothing to project, and nothing here to remove — the note is kept because
321
+ // `assets.json` (the committed local path → id map) is a DIFFERENT thing and the
322
+ // two get confused.
276
323
 
277
324
  return result
278
325
  }
package/src/uwx/site.js CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  parseWildcardArray,
53
53
  applyWildcardOrder,
54
54
  processMarkdownFile,
55
+ fetchFromDataShorthand,
55
56
  } from '../site/content-collector.js'
56
57
  import { normalizeHideIn } from '../site/nav-visibility.js'
57
58
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
@@ -223,11 +224,7 @@ function buildPageData(config, ctx) {
223
224
  // ⭐ A `data:` LIST means "fetch each" — one declaration per entry. Before
224
225
  // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
225
226
  // carried one dataset for a page that asked for several.
226
- let fetch =
227
- config.fetch ??
228
- (config.data
229
- ? (Array.isArray(config.data) ? config.data.map((query) => ({ query })) : { query: config.data })
230
- : undefined)
227
+ let fetch = config.fetch ?? fetchFromDataShorthand(config.data)
231
228
  // Resolve the authored `query:` shorthand to the runtime-fetchable
232
229
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
233
230
  // A shell/backend-hosted site renders client-side with NO prerender, so the
@@ -955,52 +952,113 @@ function secretsNested(siteYml) {
955
952
  )
956
953
  }
957
954
 
958
- // ── `config` — the site's authored configuration ──────────────────────────────
955
+ // ── `settings` — the site's authored configuration ────────────────────────────
959
956
  //
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`.
957
+ // ⭐ THE LINE IS IDENTITY vs CONFIGURATION. `info` answers *"which site is this?"*
958
+ // the name/label record a card or a select dropdown renders, and the set a listing
959
+ // can filter on. `settings` answers *"what does this site render with?"*.
964
960
  //
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.
961
+ // A `single` Section: one record, each block verbatim under its own key. Verbatim is
962
+ // the point — the authored shape is a nested map and it comes back as one, so
963
+ // nothing has to be flattened on push or rebuilt on pull.
968
964
  //
969
965
  // ⚠️ 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`.
966
+ // writes each key back to its authored home (`site.yml`, `theme.yml`, `head.html`).
967
+ // So it is named flatly for what it holds, like `pages` / `queries` / `records`.
973
968
  //
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
969
+ // NEVER EMIT `{}`. On a `single` Section the value must be an object, so `{}` is
970
+ // ONE RECORD WITH NO FIELDS, not zero records there is no `[]` analogue. With
971
+ // nineteen fields on it, `{}` means *clear all nineteen*. Emit only what the file
972
+ // declares; if an explicit clear is ever wanted, ask backend for a real form rather
999
973
  // 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
974
+ //
975
+ // AND EVERY KEY HERE MUST ROUND-TRIP — an authored value that reaches the server
976
+ // and cannot come back is data loss with a delay on it (uwx-format.md § THE
977
+ // ROUND-TRIP LAW). `site-project.js::SETTINGS_TO_SITE_YML` plus its explicit
978
+ // branches is the other half, and `producer-list-drift.test.js` fails if a key
979
+ // emitted here has neither.
980
+ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations } = {}) {
981
+ const settings = {}
982
+
983
+ // Site-wide values an author declares once and references from page content as
984
+ // ordinary Loom variables (`{vendor.email}`). The Section's first field.
985
+ setIf(settings, 'placeholders', siteYml.placeholders)
986
+
987
+ // Verbatim authored blocks. ⭐ `theme` is `theme.yml` VERBATIM — the built theme
988
+ // is computed (defaults filled, palettes generated), so comparing the two shows a
989
+ // difference every time and a propagation check has to say which artifact it holds.
990
+ if (themeYml && Object.keys(themeYml).length > 0) settings.theme = themeYml
991
+ setIf(settings, 'head_html', headHtml)
992
+ setIf(settings, 'fetcher', siteYml.fetcher)
993
+ setIf(settings, 'build', siteYml.build)
994
+ setIf(settings, 'paths', siteYml.paths)
995
+ setIf(settings, 'base', siteYml.base)
996
+
997
+ // Locale configuration.
998
+ //
999
+ // ⚠️ THESE MOVED, WERE REVERTED, AND MOVED AGAIN IN ONE DAY — the history is here
1000
+ // so nobody re-litigates it from half of it. Backend refused the move on a measured
1001
+ // constraint: three call sites read these off `entity.brief`, and their
1002
+ // default-locale accessor falls back to `"en"` without erroring, so moving them
1003
+ // would have resolved every site to English silently. Framework reverted.
1004
+ //
1005
+ // ⭐ The constraint had already been LIFTED when the refusal was written. Backend
1006
+ // had reworked those readers to take the stored Item through a name-keyed accessor
1007
+ // rather than the brief, then made the move (generation 15) — and did not withdraw
1008
+ // the refusal. So framework reverted against a rule its author had personally
1009
+ // retired an hour earlier. **The fixture was current; the message was stale.**
1010
+ //
1011
+ // ⇒ `settings` is where they live. `publish_languages` rides VERBATIM, dangling
1012
+ // codes included: sync carries the full working set and only *publish* filters,
1013
+ // which preserves a locale's publish intent across a remove + re-add in `languages:`.
1014
+ setIf(settings, 'languages', siteYml.languages)
1015
+ setIf(settings, 'default_language', siteYml.defaultLanguage)
1016
+ setIf(settings, 'publish_languages', siteYml.publishLanguages)
1017
+
1018
+ // SEO. ⛔ `seo` is SIX crawler/sitemap directives and one card field — `image`,
1019
+ // `ogTitle`, `ogDescription`, `noindex`, `canonical`, `changefreq`, `priority`
1020
+ // (`core/src/seo.js`). Two of those are literally sitemap.xml columns. It only
1021
+ // ever passed the card test because `image` was inside it; the card's picture is
1022
+ // `info.preview_image` now.
1023
+ setIf(settings, 'seo', siteYml.seo)
1024
+ // ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
1025
+ // (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
1026
+ // convenience, not because it is a different kind of thing. Localized, so it
1027
+ // carries the translation collector with it.
1028
+ setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
1029
+
1030
+ // Authored service declarations. ⛔ These must NOT be filed with the `$services`
1031
+ // Section: authored ones resolve at the SITE tier (`config.<name>`, first choice
1032
+ // in `@uniweb/core`'s `resolveService`) while `$services` is the HOST tier, where
1033
+ // a block's mere PRESENCE declines every service it does not name
1034
+ // (`core/src/services.js`). Moving them there would invert their precedence and
1035
+ // turn a site's own search off with no error and no message.
1036
+ setIf(settings, 'search', siteYml.search)
1037
+ setIf(settings, 'submit', siteYml.submit)
1038
+ // ⛔ Credentials are stripped, not trusted — this block is published world-readable.
1039
+ setIf(settings, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
1040
+ setIf(settings, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
1041
+
1042
+ // Projections opt-out + route exclusions. Carried because the app is a second
1043
+ // PUBLISHER of projections and derives them from stored content: without this it
1044
+ // cannot see `agents: false` or `agents.exclude`, so an author's opt-out is
1045
+ // silently reversed and an excluded branch becomes both discoverable AND
1046
+ // summarized.
1047
+ setIf(settings, 'agents', siteYml.agents)
1048
+
1049
+ // ⭐ The site-level fetch, DESUGARED and under its real name. `data:` is the
1050
+ // authoring shorthand for `fetch:` and every other tier already calls the wire
1051
+ // field `fetch`; the site tier called it `data` until 2026-09-09.
1052
+ setIf(settings, 'fetch', siteYml.fetch ?? fetchFromDataShorthand(siteYml.data))
1053
+
1054
+ // ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
1055
+ // the page and folder tiers have always had. `hide` is a non-destructive per-area
1056
+ // disable the runtime honours (`core/src/page.js`). Framework read only `.name`
1057
+ // at this tier and emitted nothing at all until 2026-09-09; the app had been
1058
+ // editing the field on the server with framework unable to see or carry it.
1059
+ setIf(settings, 'layout', siteYml.layout)
1060
+
1061
+ return Object.keys(settings).length > 0 ? settings : undefined
1004
1062
  }
1005
1063
 
1006
1064
  /**
@@ -1071,19 +1129,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1071
1129
  // title/slug/label/keywords, the body) stay localized. (uwx-format.md → identity-label names.)
1072
1130
  info.name = siteYml.name
1073
1131
  setIf(info, 'description', localizeScalar(siteYml.description, sourceLocale, translations))
1074
- if (themeYml && Object.keys(themeYml).length > 0) info.theme = themeYml
1075
- setIf(info, 'languages', siteYml.languages)
1076
- setIf(info, 'default_language', siteYml.defaultLanguage)
1077
1132
  // Publish intent (site.yml `publishLanguages:`) rides VERBATIM — dangling
1078
1133
  // codes included. Sync carries the full working set; only *publish* filters
1079
1134
  // (backend projection / static-build filter). The verbatim carry is what
1080
1135
  // preserves a locale's publish intent across a remove + re-add in
1081
1136
  // `languages:` (uwx-format.md → "Per-locale publish readiness").
1082
- setIf(info, 'publish_languages', siteYml.publishLanguages)
1083
1137
  // `foundation` (required) — the verbatim `site.yml::foundation` string
1084
1138
  // (registry ref / URL / local path), the round-trip source of truth.
1085
1139
  info.foundation = siteYml.foundation
1086
- setIf(info, 'base', siteYml.base)
1087
1140
  // favicon — a verbatim URL/path string. ⚠️ This comment claimed "the kit
1088
1141
  // resolves it, like other media refs" until 2026-08-17; measured, `favicon`
1089
1142
  // appears nowhere in `kit/src` or `runtime/src`. The real consumer is
@@ -1095,32 +1148,33 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1095
1148
  //
1096
1149
  // `assets` is a build-DERIVED upload manifest, not authored config, so it
1097
1150
  // is never produced from / projected to the site files.
1151
+ //
1152
+ // ⛔ AND `info.assets` IS GONE — deleted from the Model 2026-09-09 with `app`,
1153
+ // `data_bundle` and `foundation_schema`. Framework never stamped it; the only
1154
+ // deploy-derived `info` field framework sends is `foundation`, via `injectInfo`
1155
+ // in `publish`.
1156
+ //
1157
+ // ⚠️ The note stays because `assets.json` — the COMMITTED local map from an
1158
+ // author's asset path to the backend's content-addressed id — is a different
1159
+ // thing with a similar name, and it is very much alive.
1098
1160
  setIf(info, 'favicon', siteYml.favicon)
1099
1161
  // Site-level SEO/social metadata — the same shape as page.yml's `seo:` + the
1100
1162
  // top-level `keywords`, hoisted to the site root so the homepage social card
1101
1163
  // and default keywords exist for any share/SSR/crawler. `seo` rides verbatim
1102
1164
  // as authored config (round-trips like favicon); `keywords` is a localized
1103
1165
  // list (like page keywords).
1104
- setIf(info, 'seo', siteYml.seo)
1105
- setIf(info, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
1106
- setIf(info, 'head_html', headHtml)
1107
- setIf(info, 'fetcher', siteYml.fetcher)
1108
- setIf(info, 'build', siteYml.build)
1109
- setIf(info, 'search', siteYml.search)
1110
1166
  // `submit` — where this site's forms send submissions. Same family as
1111
1167
  // `fetcher`/`search`: the site declares it, the runtime reads it, and it
1112
1168
  // round-trips verbatim. It has to be listed HERE because this lane is an
1113
1169
  // explicit allowlist while the bundle lane spreads all of site.yml — without
1114
1170
  // the line a `submit:` block works on a static host and vanishes silently on
1115
1171
  // the synced lane, which is the worst shape a config bug can take.
1116
- setIf(info, 'submit', siteYml.submit)
1117
1172
  // `agents` — the projections opt-out + route exclusions. Carried because the
1118
1173
  // app is a second PUBLISHER of projections and derives them from stored
1119
1174
  // content: without this block it cannot see `agents: false` or
1120
1175
  // `agents.exclude`, so an author's opt-out is silently reversed and an
1121
1176
  // excluded branch becomes both discoverable AND summarized by the index.
1122
1177
  // (The CLI lane reads site.yml directly and honors it either way.)
1123
- setIf(info, 'agents', siteYml.agents)
1124
1178
  // `assistant` — the site's own declaration for an AI assistant: where it
1125
1179
  // lives (`endpoint`, read by kit's `resolveService`) plus authored settings a
1126
1180
  // host reads (`system` persona, model hints). Same family as
@@ -1136,7 +1190,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1136
1190
  // line.
1137
1191
  //
1138
1192
  // ⛔ Credentials are stripped, not trusted — see `stripCredentials`.
1139
- setIf(info, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
1140
1193
  // `tracking` — where this site's usage events go (`endpoint`, read by the
1141
1194
  // runtime through `resolveService`, plus `consent:`). Same family as
1142
1195
  // `search`/`submit`/`assistant` and here for the same reason: the bundle lane
@@ -1150,7 +1203,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1150
1203
  // (`https://collector/e?key=…`) is invisible here and is disclosed. The host's
1151
1204
  // secret store is the only right home either way.
1152
1205
 
1153
- setIf(info, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
1154
1206
  // ⛔ `api` IS DELIBERATELY NOT HERE, and this note exists because every comment
1155
1207
  // above it argues the opposite — three services are on this allowlist precisely so
1156
1208
  // an authored block cannot work on a static host and vanish on the synced one.
@@ -1170,14 +1222,27 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1170
1222
  // with the RIGHT one.
1171
1223
  //
1172
1224
  // The provisioned record rides the `$services` section instead (see servicesNested).
1173
- setIf(info, 'paths', siteYml.paths)
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.
1225
+ // ⭐ DESUGARED, like every other tier. `data:` is the shorthand for `fetch:`
1226
+ // (`data: articles` → `{ query: 'articles' }`), and the page level has always
1227
+ // desugared before emitting. The site level shipped the bare string until
1228
+ // 2026-09-09, so `info.data` carried two different shapes depending on which
1229
+ // key the author happened to type.
1230
+ //
1231
+ // 📌 The wire NAME is still `data` and becomes `fetch` when the Section moves —
1232
+ // renaming it now would be a second destructive wire change for a cosmetic gain;
1233
+ // renaming it during the move is free.
1234
+ // ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
1235
+ // (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
1236
+ // renders, plus what a listing can filter on. Eighteen keys moved off it on
1237
+ // 2026-09-09 because a brief was never meant to carry configuration.
1238
+ //
1239
+ // ⛔ AND THIS ALLOWLIST IS A KNOWN LIABILITY, not a design to copy. `info` is built
1240
+ // from a fixed list of keys framework knows, so any AUTHORED field on the Model that
1241
+ // framework does not model is dropped on pull and — on a Section that replaces
1242
+ // wholesale — destroyed on push, silently. That is what `queriesNested` uses a
1243
+ // DENY-list to avoid ("EMIT WHAT WE DO NOT MODEL"), and it is illegal under the
1244
+ // round-trip law (uwx-format.md). `info.layout` was the live instance and has now
1245
+ // moved to `settings`; the general defect is open.
1181
1246
  //
1182
1247
  // ⛔ `app` IS RETIRED — do not reintroduce it, in either direction. It carried an
1183
1248
  // opaque uuid naming a separate entity a host bound to the site; that entity is
@@ -1194,6 +1259,19 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1194
1259
  // backend applies a clonability designation to this site-content entity (it is
1195
1260
  // NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
1196
1261
  setIf(info, 'template', siteYml.template)
1262
+ // ⭐ `tags` — the filter facet for a list of site cards, chiefly the template
1263
+ // picker. An array of NON-LOCALIZED tokens (`[academic, portfolio]`); the site
1264
+ // never renders them and carries no labels for them, because the chip a user
1265
+ // reads belongs to the picker and is translated app-side from a vocabulary it
1266
+ // knows. Two filterings were being discussed as one: the picker filters in JS
1267
+ // over an already-fetched list and never asks the database, while a DB-filterable
1268
+ // facet is separately useful — only the second needs a predicable brief field.
1269
+ setIf(info, 'tags', siteYml.tags)
1270
+ // ⛔ `url` and `preview_image` are BACKEND-STAMPED and framework emits NEITHER.
1271
+ // A site's live address is assigned at publish and its card image needs a servable
1272
+ // URL; a serve location is a per-response answer the host owns — read, never
1273
+ // constructed. `site-project.js` must also never write them into `site.yml`, or a
1274
+ // pull launders a deploy-derived value into authored config.
1197
1275
 
1198
1276
  const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
1199
1277
  const pagesPath = siteYml.paths?.pages
@@ -1232,9 +1310,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1232
1310
  doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
1233
1311
  doc.$model = SITE_MODEL_NAME
1234
1312
  doc.info = info
1235
- // Emitted only when the file declares something — see `configNested`.
1236
- const config = configNested(siteYml)
1237
- if (config) doc.config = config
1313
+ // Emitted only when the file declares something — see `settingsNested`.
1314
+ const settings = settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translations })
1315
+ if (settings) doc.settings = settings
1238
1316
  doc.pages = pages
1239
1317
  doc.layout_sections = layoutSections
1240
1318
  doc.extensions = extensionsNested(siteYml)
@@ -220,8 +220,13 @@ function rewriteEntityAssets(node, map, ids) {
220
220
  * to push unconditionally — that IS the force path.
221
221
  * @param {boolean} [opts.includeSite] - include the site-content lane (default true)
222
222
  * @param {object} [opts.injectInfo] - deploy-derived `info.*` to stamp on the
223
- * site-content document (e.g. `{ data_bundle }`, the static-data ball URL);
224
- * wire-only never authored in site.yml, never projected back on pull.
223
+ * site-content document. ⭐ TODAY THAT IS `{ foundation }` AND NOTHING ELSE
224
+ * the released ref, stamped by `publish` when it releases a local foundation.
225
+ * Deploy-derived means wire-only: never authored in site.yml, never projected
226
+ * back on pull, which is the round-trip law's derived exception.
227
+ * ⚠️ This example read `{ data_bundle }` until 2026-09-09; that field was
228
+ * RETIRED 2026-08-18 (the static-data ball is gone — collection data lands at
229
+ * its serving tail), so the example named a key nothing emits.
225
230
  * @param {Object<string,string>} [opts.injectExtensions] - authored extension
226
231
  * declaration (`$id`) → the pinned `@scope/name@version` to stamp over it.
227
232
  * `publish` fills this for the site's LOCAL extensions after releasing them;
@@ -313,10 +318,14 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
313
318
  ...(opts.declareServices === false ? { declareServices: false } : {})
314
319
  })
315
320
  : null
316
- // Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
317
- // stamped here NOT authored in site.yml, so they ride the wire but never project
318
- // back on pull (the `info.assets` precedent). They are part of the hashed content,
319
- // so a changed bundle URL correctly re-fires the site-content lane.
321
+ // Deploy-derived `info` fields are stamped here NOT authored in site.yml, so they
322
+ // ride the wire but never project back on pull. They are part of the hashed content,
323
+ // so a changed value correctly re-fires the site-content lane.
324
+ //
325
+ // ⭐ TODAY THE SET IS `{ foundation }` AND NOTHING ELSE. ⚠️ This comment named
326
+ // `data_bundle` (retired 2026-08-18) as the example and `info.assets` as the
327
+ // precedent; framework stamps neither, so both were pointing a reader at keys this
328
+ // producer does not write.
320
329
  const injectInfo =
321
330
  opts.injectInfo && typeof opts.injectInfo === 'object' ? opts.injectInfo : null
322
331
  if (siteDoc && injectInfo) {