@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 +7 -7
- package/src/schema.js +19 -6
- package/src/site/content-collector.js +122 -47
- package/src/uwx/site-project.js +94 -22
- package/src/uwx/site.js +177 -24
- package/src/uwx/sync-package.js +21 -7
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": {
|
|
@@ -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/
|
|
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.
|
|
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.
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
767
|
-
`
|
|
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
|
-
|
|
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) {
|
|
@@ -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 } =
|
|
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
|
|
2420
|
-
|
|
2421
|
-
|
|
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,
|
|
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
|
|
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
|
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,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
|
-
|
|
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',
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 (
|
|
240
|
-
result.theme = writeThemeFile(siteRoot,
|
|
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 (
|
|
245
|
-
result.headHtml = writeIfChanged(join(siteRoot, '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`
|
|
250
|
-
//
|
|
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
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
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
|
}
|
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;
|
|
@@ -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
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
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) {
|