@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 +4 -4
- package/src/site/content-collector.js +86 -43
- package/src/uwx/site-project.js +77 -30
- package/src/uwx/site.js +149 -71
- package/src/uwx/sync-package.js +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
62
|
-
"@uniweb/projections": "^0.5.13",
|
|
61
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
63
62
|
"@uniweb/content-writer": "^0.3.4",
|
|
64
|
-
"@uniweb/
|
|
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
|
-
|
|
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
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
const
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
1822
|
-
|
|
1823
|
-
|
|
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,
|
|
1874
|
+
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
1832
1875
|
const isIndex = entry === indexName
|
|
1833
|
-
const effectiveLayout =
|
|
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
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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,
|
|
2003
|
+
const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
|
|
1963
2004
|
const isIndex = entry === indexPageName
|
|
1964
|
-
const effectiveLayout =
|
|
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
|
-
|
|
1995
|
-
|
|
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
|
-
|
|
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
|
|
2452
|
-
|
|
2453
|
-
|
|
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,
|
|
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
|
|
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
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
// ── `
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
214
|
-
//
|
|
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
|
|
217
|
-
|
|
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 (
|
|
265
|
-
result.theme = writeThemeFile(siteRoot,
|
|
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 (
|
|
270
|
-
result.headHtml = writeIfChanged(join(siteRoot, '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`
|
|
275
|
-
//
|
|
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
|
-
// ── `
|
|
955
|
+
// ── `settings` — the site's authored configuration ────────────────────────────
|
|
959
956
|
//
|
|
960
|
-
// ⭐ THE LINE IS IDENTITY vs CONFIGURATION. `info` answers *"which site is this?"*
|
|
961
|
-
//
|
|
962
|
-
// `
|
|
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,
|
|
966
|
-
//
|
|
967
|
-
//
|
|
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
|
|
971
|
-
//
|
|
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
|
-
//
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
//
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
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 `
|
|
1236
|
-
const
|
|
1237
|
-
if (
|
|
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)
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -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
|
|
224
|
-
*
|
|
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
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
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) {
|