@uniweb/build 0.43.0 → 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.0",
3
+ "version": "0.44.0",
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/semantic-parser": "^1.4.0",
62
+ "@uniweb/content-writer": "^0.3.4",
63
+ "@uniweb/theming": "^0.1.15",
64
+ "@uniweb/projections": "^0.5.13"
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
  }
@@ -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) {
@@ -2095,11 +2132,19 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2095
2132
  /**
2096
2133
  * Load foundation schema data needed by the content collector.
2097
2134
  *
2135
+ * `hasContentHandler` reports whether the foundation declares `handlers.content`
2136
+ * — the hook that resolves `{…}` in page content. It is NOT used to decide
2137
+ * anything about the build; its only consumer is the `placeholders:` warning at
2138
+ * the call site, which needs to know whether a declared value has any reader.
2139
+ * ⚠️ It cannot tell WHICH engine the handler uses (a handler is a function, and
2140
+ * the build never calls it), so it answers "something could resolve this",
2141
+ * never "Loom will".
2142
+ *
2098
2143
  * @param {string} foundationPath - Path to foundation directory
2099
- * @returns {Promise<{ vars: Object, layoutNames: Set<string> }>}
2144
+ * @returns {Promise<{ vars: Object, layoutNames: Set<string>, hasContentHandler: boolean }>}
2100
2145
  */
2101
2146
  export async function loadFoundationInfo(foundationPath) {
2102
- if (!foundationPath) return { vars: {}, layoutNames: new Set() }
2147
+ if (!foundationPath) return { vars: {}, layoutNames: new Set(), hasContentHandler: false }
2103
2148
 
2104
2149
  // ⛔ **NOT `dist/meta/schema.json`.** That file is the EDITOR's artifact — the
2105
2150
  // rich per-section declaration a visual editor needs to render parameter forms
@@ -2133,9 +2178,13 @@ export async function loadFoundationInfo(foundationPath) {
2133
2178
  // Two independent reads, so a failure in one does not cost the other. The
2134
2179
  // previous single try/catch lost the layouts when only the config was broken.
2135
2180
  let vars = {}
2181
+ let hasContentHandler = false
2136
2182
  try {
2137
2183
  const config = await loadFoundationConfig(srcDir)
2138
2184
  vars = config?.vars || {}
2185
+ // `loadFoundationConfig` spreads the module's default export, so `handlers`
2186
+ // arrives intact even though it holds functions and never reaches schema.json.
2187
+ hasContentHandler = typeof config?.handlers?.content === 'function'
2139
2188
  } catch (err) {
2140
2189
  console.warn(
2141
2190
  `[content-collector] Could not read the foundation's declared theme vars from ${srcDir}: ${err.message}\n` +
@@ -2154,7 +2203,7 @@ export async function loadFoundationInfo(foundationPath) {
2154
2203
  )
2155
2204
  }
2156
2205
 
2157
- return { vars, layoutNames }
2206
+ return { vars, layoutNames, hasContentHandler }
2158
2207
  }
2159
2208
 
2160
2209
  /**
@@ -2372,7 +2421,27 @@ export async function collectSiteContent(sitePath, options = {}) {
2372
2421
  const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
2373
2422
 
2374
2423
  // Load foundation info (vars + layout names) and process theme
2375
- const { vars: foundationVars, layoutNames: layoutNames } = await loadFoundationInfo(foundationPath)
2424
+ const { vars: foundationVars, layoutNames: layoutNames, hasContentHandler } =
2425
+ await loadFoundationInfo(foundationPath)
2426
+
2427
+ // ⭐ `placeholders:` IS DECLARED FOR A READER THAT MAY NOT EXIST, and that is
2428
+ // the one way this feature fails. Resolving `{…}` in page content is a
2429
+ // FOUNDATION capability (`handlers.content`, normally @uniweb/loom), not
2430
+ // something the framework does for every site — so on a foundation that
2431
+ // declares no content handler the block is inert and the page renders the
2432
+ // literal `{vendor.email}`. That reads as an authoring typo, which is why it
2433
+ // is worth a build-time line rather than leaving the author to find it.
2434
+ //
2435
+ // ⚖️ A WARNING, never an error: the site may be mid-migration, or the author
2436
+ // may be about to switch foundations, and a declared-but-unread value harms
2437
+ // nothing. Same rule as the retired-`fetcher:` keys above — warn once, carry on.
2438
+ if (siteConfig.placeholders && !hasContentHandler) {
2439
+ console.warn(
2440
+ `[uniweb] site.yml declares \`placeholders:\` but the foundation has no \`handlers.content\`, ` +
2441
+ `so nothing will resolve them — pages will render the literal \`{name}\` text.\n` +
2442
+ `[uniweb] A foundation opts in with \`handlers: createLoomHandlers({ vars })\` from @uniweb/loom.`
2443
+ )
2444
+ }
2376
2445
  // `base` reaches the theme because self-hosted font faces are authored
2377
2446
  // root-relative (`/fonts/x.woff2`) and the emitted @font-face lives in an
2378
2447
  // inline <style> — under a subdirectory deployment it must carry the base.
@@ -2416,13 +2485,13 @@ export async function collectSiteContent(sitePath, options = {}) {
2416
2485
  // Collect layout areas from layout/ directory (including named layout subdirectories)
2417
2486
  const { layouts } = await collectLayouts(layoutPath, sitePath, layoutNames)
2418
2487
 
2419
- // Site-level layout name (from site.yml layout: field)
2420
- const siteLayoutName = typeof siteConfig.layout === 'string' ? siteConfig.layout
2421
- : 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)
2422
2491
 
2423
2492
  // Recursively collect all pages
2424
2493
  let { pages, assetCollection, iconCollection, notFound, versionedScopes } =
2425
- await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayoutName)
2494
+ await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayout)
2426
2495
 
2427
2496
  // Merge top-level config assets (e.g. document.yml's book.covers.front,
2428
2497
  // banner images, logos) into the manifest. The compile pipeline reads
@@ -2611,7 +2680,13 @@ export async function collectSiteContent(sitePath, options = {}) {
2611
2680
  ...(publishFilterActive && Array.isArray(siteConfig.languages)
2612
2681
  ? { languages: publishable }
2613
2682
  : {}),
2614
- 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)),
2615
2690
  fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
2616
2691
  // NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
2617
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,17 +135,62 @@ 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',
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.
152
+ }
153
+
154
+ // ── `settings` Section → site.yml ─────────────────────────────────────────────
155
+ //
156
+ // The `config` Section (see `site.js::configNested`) carries authored
157
+ // configuration that does not belong on `info`. Each key maps to a
158
+ // top-level `site.yml` key of the same name, verbatim, so the author's file
159
+ // round-trips unchanged.
160
+ //
161
+ // ⛔ THIS MAP IS THE HALF THAT GETS LEFT OUT. The push side tests green entirely
162
+ // on its own, so a missing entry here is invisible until someone pulls and finds
163
+ // their block gone from site.yml. Every key `configNested` emits needs a line.
164
+ //
165
+ // 📌 `theme` will belong here after the stage-2 move off `info` — it is projected
166
+ // to `theme.yml` (not site.yml) and so will need its own handling, not a row.
167
+ const SETTINGS_TO_SITE_YML = {
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',
155
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',
156
191
  }
157
192
 
193
+
158
194
  /**
159
195
  * Project a site-content document's `info` (+ `extensions`) onto the site's
160
196
  * config files: `site.yml`, `theme.yml`, and `head.html`. Idempotent; only the
@@ -170,6 +206,7 @@ const INFO_TO_SITE_YML = {
170
206
  */
171
207
  export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
172
208
  const info = document?.info || {}
209
+ const settingsSection = document?.settings || {}
173
210
 
174
211
  const siteChanges = {}
175
212
  // Localized text fields → unwrapped to the source locale (the target locales are
@@ -183,9 +220,9 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
183
220
 
184
221
  // `keywords` is a localized list (mirrors page keywords) → unwrap to the
185
222
  // source locale; the target locales are captured into the locales/ collector.
186
- if (Array.isArray(info.keywords)) info.keywords.forEach((kw) => collector?.add(kw))
187
- const keywords = unwrapLocalizedList(info.keywords, sourceLocale)
188
- 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.)
189
226
 
190
227
  // Verbatim fields (includes `seo` — the site-level social/SEO block).
191
228
  for (const [infoKey, ymlKey] of Object.entries(INFO_TO_SITE_YML)) {
@@ -193,6 +230,37 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
193
230
  if (info[infoKey] !== undefined) siteChanges[ymlKey] = info[infoKey]
194
231
  }
195
232
 
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
253
+ // the document does not carry writes nothing, like every other absent key here.
254
+ for (const [settingsKey, ymlKey] of Object.entries(SETTINGS_TO_SITE_YML)) {
255
+ if (settingsSection[settingsKey] !== undefined) siteChanges[ymlKey] = settingsSection[settingsKey]
256
+ }
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
+
196
264
  // extensions[] → site.yml::extensions. Each entry carries EITHER `ref` (a
197
265
  // catalog ref or a local name — an extension is a foundation and is declared
198
266
  // like one) OR `url`. Project back whichever is present so a ref survives a
@@ -236,18 +304,22 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
236
304
  const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
237
305
 
238
306
  // theme (whole object) → theme.yml.
239
- if (info.theme && typeof info.theme === 'object') {
240
- result.theme = writeThemeFile(siteRoot, info.theme)
307
+ if (settingsSection.theme && typeof settingsSection.theme === 'object') {
308
+ result.theme = writeThemeFile(siteRoot, settingsSection.theme)
241
309
  }
242
310
 
243
311
  // head_html → head.html (a raw file, not YAML).
244
- if (info.head_html != null) {
245
- 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)
246
314
  }
247
315
 
248
316
  // `info.favicon` rides the verbatim INFO_TO_SITE_YML map above (→ site.yml).
249
- // `info.assets` is intentionally NOT projected: it is a build-derived upload
250
- // 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.
251
323
 
252
324
  return result
253
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,6 +952,115 @@ function secretsNested(siteYml) {
955
952
  )
956
953
  }
957
954
 
955
+ // ── `settings` — the site's authored configuration ────────────────────────────
956
+ //
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?"*.
960
+ //
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.
964
+ //
965
+ // ⚠️ AUTHORS NEVER SEE THIS NAME. It is a wire and Model name; `site-project.js`
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`.
968
+ //
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
973
+ // than inferring one from an empty object.
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
1062
+ }
1063
+
958
1064
  /**
959
1065
  * Map a file site project to the nested `@uniweb/site-content` `$`-document
960
1066
  * (see the lane header above). PURE — reads the project, never mints, never writes.
@@ -1023,19 +1129,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1023
1129
  // title/slug/label/keywords, the body) stay localized. (uwx-format.md → identity-label names.)
1024
1130
  info.name = siteYml.name
1025
1131
  setIf(info, 'description', localizeScalar(siteYml.description, sourceLocale, translations))
1026
- if (themeYml && Object.keys(themeYml).length > 0) info.theme = themeYml
1027
- setIf(info, 'languages', siteYml.languages)
1028
- setIf(info, 'default_language', siteYml.defaultLanguage)
1029
1132
  // Publish intent (site.yml `publishLanguages:`) rides VERBATIM — dangling
1030
1133
  // codes included. Sync carries the full working set; only *publish* filters
1031
1134
  // (backend projection / static-build filter). The verbatim carry is what
1032
1135
  // preserves a locale's publish intent across a remove + re-add in
1033
1136
  // `languages:` (uwx-format.md → "Per-locale publish readiness").
1034
- setIf(info, 'publish_languages', siteYml.publishLanguages)
1035
1137
  // `foundation` (required) — the verbatim `site.yml::foundation` string
1036
1138
  // (registry ref / URL / local path), the round-trip source of truth.
1037
1139
  info.foundation = siteYml.foundation
1038
- setIf(info, 'base', siteYml.base)
1039
1140
  // favicon — a verbatim URL/path string. ⚠️ This comment claimed "the kit
1040
1141
  // resolves it, like other media refs" until 2026-08-17; measured, `favicon`
1041
1142
  // appears nowhere in `kit/src` or `runtime/src`. The real consumer is
@@ -1047,32 +1148,33 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1047
1148
  //
1048
1149
  // `assets` is a build-DERIVED upload manifest, not authored config, so it
1049
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.
1050
1160
  setIf(info, 'favicon', siteYml.favicon)
1051
1161
  // Site-level SEO/social metadata — the same shape as page.yml's `seo:` + the
1052
1162
  // top-level `keywords`, hoisted to the site root so the homepage social card
1053
1163
  // and default keywords exist for any share/SSR/crawler. `seo` rides verbatim
1054
1164
  // as authored config (round-trips like favicon); `keywords` is a localized
1055
1165
  // list (like page keywords).
1056
- setIf(info, 'seo', siteYml.seo)
1057
- setIf(info, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
1058
- setIf(info, 'head_html', headHtml)
1059
- setIf(info, 'fetcher', siteYml.fetcher)
1060
- setIf(info, 'build', siteYml.build)
1061
- setIf(info, 'search', siteYml.search)
1062
1166
  // `submit` — where this site's forms send submissions. Same family as
1063
1167
  // `fetcher`/`search`: the site declares it, the runtime reads it, and it
1064
1168
  // round-trips verbatim. It has to be listed HERE because this lane is an
1065
1169
  // explicit allowlist while the bundle lane spreads all of site.yml — without
1066
1170
  // the line a `submit:` block works on a static host and vanishes silently on
1067
1171
  // the synced lane, which is the worst shape a config bug can take.
1068
- setIf(info, 'submit', siteYml.submit)
1069
1172
  // `agents` — the projections opt-out + route exclusions. Carried because the
1070
1173
  // app is a second PUBLISHER of projections and derives them from stored
1071
1174
  // content: without this block it cannot see `agents: false` or
1072
1175
  // `agents.exclude`, so an author's opt-out is silently reversed and an
1073
1176
  // excluded branch becomes both discoverable AND summarized by the index.
1074
1177
  // (The CLI lane reads site.yml directly and honors it either way.)
1075
- setIf(info, 'agents', siteYml.agents)
1076
1178
  // `assistant` — the site's own declaration for an AI assistant: where it
1077
1179
  // lives (`endpoint`, read by kit's `resolveService`) plus authored settings a
1078
1180
  // host reads (`system` persona, model hints). Same family as
@@ -1088,7 +1190,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1088
1190
  // line.
1089
1191
  //
1090
1192
  // ⛔ Credentials are stripped, not trusted — see `stripCredentials`.
1091
- setIf(info, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
1092
1193
  // `tracking` — where this site's usage events go (`endpoint`, read by the
1093
1194
  // runtime through `resolveService`, plus `consent:`). Same family as
1094
1195
  // `search`/`submit`/`assistant` and here for the same reason: the bundle lane
@@ -1102,7 +1203,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1102
1203
  // (`https://collector/e?key=…`) is invisible here and is disclosed. The host's
1103
1204
  // secret store is the only right home either way.
1104
1205
 
1105
- setIf(info, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
1106
1206
  // ⛔ `api` IS DELIBERATELY NOT HERE, and this note exists because every comment
1107
1207
  // above it argues the opposite — three services are on this allowlist precisely so
1108
1208
  // an authored block cannot work on a static host and vanish on the synced one.
@@ -1122,8 +1222,28 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1122
1222
  // with the RIGHT one.
1123
1223
  //
1124
1224
  // The provisioned record rides the `$services` section instead (see servicesNested).
1125
- setIf(info, 'paths', siteYml.paths)
1126
- setIf(info, 'data', siteYml.data ?? siteYml.fetch)
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.
1246
+ //
1127
1247
  // ⛔ `app` IS RETIRED — do not reintroduce it, in either direction. It carried an
1128
1248
  // opaque uuid naming a separate entity a host bound to the site; that entity is
1129
1249
  // gone, a site's services belong to the site itself, and NOTHING replaces the key.
@@ -1139,6 +1259,19 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1139
1259
  // backend applies a clonability designation to this site-content entity (it is
1140
1260
  // NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
1141
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.
1142
1275
 
1143
1276
  const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
1144
1277
  const pagesPath = siteYml.paths?.pages
@@ -1177,6 +1310,9 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1177
1310
  doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
1178
1311
  doc.$model = SITE_MODEL_NAME
1179
1312
  doc.info = info
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
1180
1316
  doc.pages = pages
1181
1317
  doc.layout_sections = layoutSections
1182
1318
  doc.extensions = extensionsNested(siteYml)
@@ -1191,9 +1327,26 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1191
1327
  // Emitted ONLY when the file declares the key — see the header above
1192
1328
  // `serviceRecords`: on a replaced Section, absent and empty are different
1193
1329
  // requests and one of them is destructive.
1194
- const services = servicesNested(siteYml)
1330
+ //
1331
+ // ⭐ AND ONLY WHEN THE CALLER SAYS THE DECLARATION IS A REQUEST.
1332
+ // `opts.declareServices === false` withholds both Sections for THIS push, which
1333
+ // is not the same as the file having no key — the file still declares one; the
1334
+ // caller has determined the owner is not asking for anything new by it.
1335
+ //
1336
+ // ⛔ Why this decision cannot live here: the Sections are REPLACED wholesale by
1337
+ // what we send (`SectionScope::DeclaredOnly`), so re-sending an unchanged block
1338
+ // OVERWRITES whatever the stored request has become since — including a decision
1339
+ // the owner made in the app, where the consent workflow's publish happens. But
1340
+ // "has it changed since we last agreed?" needs the last agreed state, which is
1341
+ // project memory (`deploy.yml`) the CLI owns and this pure mapper must not read.
1342
+ // ⇒ The CLI decides; this honours the decision.
1343
+ //
1344
+ // ⚖️ Default is to declare, so every existing caller is unchanged and the
1345
+ // withholding is opt-in.
1346
+ const declare = opts.declareServices !== false
1347
+ const services = declare ? servicesNested(siteYml) : undefined
1195
1348
  if (services) doc.services = services
1196
- const secrets = secretsNested(siteYml)
1349
+ const secrets = declare ? secretsNested(siteYml) : undefined
1197
1350
  if (secrets) doc.secrets = secrets
1198
1351
  return doc
1199
1352
  }
@@ -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;
@@ -305,13 +310,22 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
305
310
  const siteDoc = includeSite
306
311
  ? await siteProjectToDocument(siteRoot, {
307
312
  sourceLocale,
308
- ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {})
313
+ ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
314
+ // Withhold the `$services`/`$secrets` Sections when the caller has
315
+ // determined the file is not asking for anything new by them. Passed
316
+ // through rather than decided here: the last-agreed state is project
317
+ // memory the CLI owns. See site.js at `declareServices`.
318
+ ...(opts.declareServices === false ? { declareServices: false } : {})
309
319
  })
310
320
  : null
311
- // Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
312
- // stamped here NOT authored in site.yml, so they ride the wire but never project
313
- // back on pull (the `info.assets` precedent). They are part of the hashed content,
314
- // 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.
315
329
  const injectInfo =
316
330
  opts.injectInfo && typeof opts.injectInfo === 'object' ? opts.injectInfo : null
317
331
  if (siteDoc && injectInfo) {