@uniweb/build 0.14.21 → 0.14.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.14.21",
3
+ "version": "0.14.23",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,11 +59,11 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.33.2",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/content-writer": "0.2.6",
63
- "@uniweb/theming": "0.1.4"
62
+ "@uniweb/theming": "0.1.5",
63
+ "@uniweb/content-writer": "0.2.6"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/runtime": "0.8.20",
66
+ "@uniweb/runtime": "0.8.22",
67
67
  "@uniweb/content-reader": "1.1.12",
68
68
  "@uniweb/schemas": "0.2.4"
69
69
  },
@@ -74,7 +74,7 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "0.7.14"
77
+ "@uniweb/core": "0.7.16"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
package/src/prerender.js CHANGED
@@ -138,9 +138,20 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
138
138
  * @param {function} onProgress - Progress callback
139
139
  * @returns {Array} Expanded pages array with dynamic pages replaced by concrete instances
140
140
  */
141
- function expandDynamicPages(pages, pageFetchedData, onProgress) {
141
+ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
142
142
  const expandedPages = []
143
143
 
144
+ // Static pages win over the dynamic `[slug]` catch-all, matching the SPA's
145
+ // route resolution (Website.getPage checks exact static routes before the
146
+ // `:param` loop). Without this guard, a record whose param value collides
147
+ // with a static sibling's segment (e.g. slug:'about' + a static /blog/about)
148
+ // would emit a duplicate concrete route; the write loops are keyed on
149
+ // page.route and last-writer-wins, silently clobbering the static page's
150
+ // HTML. Collect the static routes up front so we can skip + warn on collision.
151
+ const staticRoutes = new Set(
152
+ pages.filter((p) => !p.isDynamic).map((p) => p.route)
153
+ )
154
+
144
155
  for (const page of pages) {
145
156
  if (!page.isDynamic) {
146
157
  // Regular page - include as-is
@@ -187,6 +198,13 @@ function expandDynamicPages(pages, pageFetchedData, onProgress) {
187
198
  // Create concrete route: /blog/:slug → /blog/my-post
188
199
  const concreteRoute = page.route.replace(`:${paramName}`, paramValue)
189
200
 
201
+ // Static sibling wins: skip a record whose concrete route collides with
202
+ // an existing static page rather than overwriting its HTML at write time.
203
+ if (staticRoutes.has(concreteRoute)) {
204
+ onProgress(` Skipping ${concreteRoute} — a static page already claims this route (${paramName}:'${paramValue}')`)
205
+ continue
206
+ }
207
+
190
208
  // Deep clone the page with modifications
191
209
  const concretePage = JSON.parse(JSON.stringify(page))
192
210
  concretePage.route = concreteRoute
@@ -95,7 +95,9 @@ export async function buildSiteData({
95
95
 
96
96
  // 1. Collect content (pages, sections, theme, config, assets manifest).
97
97
  // No vite needed — collectSiteContent is a plain async function.
98
- let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath })
98
+ // dropUnpublished: link mode is always a published deploy — prune hidden
99
+ // pages + their subtree so drafts never reach the served site.
100
+ let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true })
99
101
 
100
102
  // 2. Compile content collections (file-based markdown/yaml/json).
101
103
  // `writeCollectionFiles` lands them under `<siteRoot>/public/data/`;
@@ -29,7 +29,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs'
29
29
  import yaml from 'js-yaml'
30
30
  import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
31
31
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
32
- import { normalizeHideIn } from './nav-visibility.js'
32
+ import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
33
33
  import { parseFetchConfig, singularize } from './data-fetcher.js'
34
34
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
35
35
 
@@ -1660,13 +1660,16 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1660
1660
  }
1661
1661
  }
1662
1662
 
1663
- // When pages: is strict (no '...'), hide unlisted direct children from navigation
1663
+ // When pages: is strict (no '...'), suppress unlisted direct children from
1664
+ // every nav area — but keep them ROUTED (strict is a nav filter, not a draft
1665
+ // toggle). This is the nav axis (`hideIn: ['*']`), not the reachability axis
1666
+ // (`hidden`), which would drop the page from the published output entirely.
1664
1667
  if (strictPageNamesFM) {
1665
1668
  for (const page of pages) {
1666
1669
  const childName = getDirectChildName(page.route, parentRoute)
1667
1670
  || (page.sourcePath ? getDirectChildName(page.sourcePath, parentRoute) : null)
1668
- if (childName && !strictPageNamesFM.has(childName) && !page.hidden) {
1669
- page.hidden = true
1671
+ if (childName && !strictPageNamesFM.has(childName) && !page.hideIn?.includes('*')) {
1672
+ page.hideIn = [...(page.hideIn || []), '*']
1670
1673
  }
1671
1674
  }
1672
1675
  }
@@ -1797,13 +1800,15 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1797
1800
  }
1798
1801
  }
1799
1802
 
1800
- // When pages: is strict (no '...'), hide unlisted direct children from navigation
1803
+ // When pages: is strict (no '...'), suppress unlisted direct children from every
1804
+ // nav area but keep them ROUTED — the nav axis (`hideIn: ['*']`), not the
1805
+ // reachability axis (`hidden`), which would drop them from the published output.
1801
1806
  if (strictPageNames) {
1802
1807
  for (const page of pages) {
1803
1808
  const childName = getDirectChildName(page.route, parentRoute)
1804
1809
  || (page.sourcePath ? getDirectChildName(page.sourcePath, parentRoute) : null)
1805
- if (childName && !strictPageNames.has(childName) && !page.hidden) {
1806
- page.hidden = true
1810
+ if (childName && !strictPageNames.has(childName) && !page.hideIn?.includes('*')) {
1811
+ page.hideIn = [...(page.hideIn || []), '*']
1807
1812
  }
1808
1813
  }
1809
1814
  }
@@ -1823,29 +1828,49 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1823
1828
  * @param {string} foundationPath - Path to foundation directory
1824
1829
  * @returns {Promise<{ vars: Object, layoutNames: Set<string> }>}
1825
1830
  */
1826
- async function loadFoundationInfo(foundationPath) {
1831
+ export async function loadFoundationInfo(foundationPath) {
1827
1832
  if (!foundationPath) return { vars: {}, layoutNames: new Set() }
1828
1833
 
1829
1834
  // Try dist/meta/schema.json first (built foundation), then root schema.json
1830
1835
  const distSchemaPath = join(foundationPath, 'dist', 'meta', 'schema.json')
1831
1836
  const rootSchemaPath = join(foundationPath, 'schema.json')
1832
1837
 
1833
- const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : rootSchemaPath
1834
-
1835
- if (!existsSync(schemaPath)) {
1836
- return { vars: {}, layoutNames: new Set() }
1838
+ const schemaPath = existsSync(distSchemaPath)
1839
+ ? distSchemaPath
1840
+ : existsSync(rootSchemaPath)
1841
+ ? rootSchemaPath
1842
+ : null
1843
+
1844
+ if (schemaPath) {
1845
+ try {
1846
+ const schemaContent = await readFile(schemaPath, 'utf8')
1847
+ const schema = JSON.parse(schemaContent)
1848
+ // Foundation config is in _self, support both 'vars' (new) and 'themeVars' (legacy)
1849
+ const vars = schema._self?.vars || schema._self?.themeVars || schema.themeVars || {}
1850
+ // Layout names from _layouts (keys are layout component names)
1851
+ const layoutNames = new Set(schema._layouts ? Object.keys(schema._layouts) : [])
1852
+ return { vars, layoutNames }
1853
+ } catch (err) {
1854
+ console.warn('[content-collector] Failed to load foundation schema:', err.message)
1855
+ // Fall through to the source-config fallback below.
1856
+ }
1837
1857
  }
1838
1858
 
1859
+ // No built schema.json — the normal state for `uniweb dev` on a bundled-mode
1860
+ // site that was never built (dev doesn't build the foundation to dist/). Read
1861
+ // the foundation's declared vars straight from its source config
1862
+ // (main.js / foundation.js) so theme tokens like --section-padding-y are
1863
+ // defined; without them, components using py-[var(--section-padding-y)] render
1864
+ // with collapsed section spacing. Layouts aren't resolved from source (they
1865
+ // need component discovery), but they aren't needed to build the theme CSS.
1839
1866
  try {
1840
- const schemaContent = await readFile(schemaPath, 'utf8')
1841
- const schema = JSON.parse(schemaContent)
1842
- // Foundation config is in _self, support both 'vars' (new) and 'themeVars' (legacy)
1843
- const vars = schema._self?.vars || schema._self?.themeVars || schema.themeVars || {}
1844
- // Layout names from _layouts (keys are layout component names)
1845
- const layoutNames = new Set(schema._layouts ? Object.keys(schema._layouts) : [])
1846
- return { vars, layoutNames }
1867
+ const { resolveFoundationSrcPath } = await import('../utils/foundation-source-root.js')
1868
+ const { loadFoundationConfig } = await import('../schema.js')
1869
+ const srcDir = resolveFoundationSrcPath(foundationPath)
1870
+ const config = await loadFoundationConfig(srcDir)
1871
+ return { vars: config?.vars || {}, layoutNames: new Set() }
1847
1872
  } catch (err) {
1848
- console.warn('[content-collector] Failed to load foundation schema:', err.message)
1873
+ console.warn('[content-collector] Failed to load foundation source config:', err.message)
1849
1874
  return { vars: {}, layoutNames: new Set() }
1850
1875
  }
1851
1876
  }
@@ -1988,7 +2013,7 @@ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
1988
2013
  * @returns {Promise<Object>} Site content object with assets manifest
1989
2014
  */
1990
2015
  export async function collectSiteContent(sitePath, options = {}) {
1991
- const { foundationPath, configFile = 'site.yml', profile: profileName } = options
2016
+ const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false } = options
1992
2017
 
1993
2018
  // Read site config and raw theme config
1994
2019
  const siteConfig = await readYamlFile(join(sitePath, configFile))
@@ -2185,7 +2210,9 @@ export async function collectSiteContent(sitePath, options = {}) {
2185
2210
  ...processedTheme,
2186
2211
  css: themeCSS
2187
2212
  },
2188
- pages,
2213
+ // Reachability axis: on the published build paths, drop `hidden` pages and
2214
+ // their whole subtree (cascade). Dev keeps them so drafts stay previewable.
2215
+ pages: dropUnpublished ? dropUnpublishedPages(pages) : pages,
2189
2216
  // Layout area sets: { default: { header: page, footer: page, ... }, marketing: { ... } }
2190
2217
  layouts,
2191
2218
  notFound,
@@ -300,6 +300,9 @@ export function parseFetchConfig(fetch) {
300
300
  where: fetch.where,
301
301
  limit: fetch.limit,
302
302
  sort: fetch.sort,
303
+ // Canonical detail page for a list card's href (page:<stable_id> ref;
304
+ // resolved to a route template + interpolated per record at runtime).
305
+ detailPage: fetch.detailPage,
303
306
  // Legacy post-processing (deprecated, see warning above)
304
307
  filter: fetch.filter,
305
308
  }
@@ -313,6 +316,7 @@ export function parseFetchConfig(fetch) {
313
316
  merge = false,
314
317
  transform,
315
318
  detail,
319
+ detailPage,
316
320
  // Query operators
317
321
  where,
318
322
  limit,
@@ -334,6 +338,8 @@ export function parseFetchConfig(fetch) {
334
338
  merge,
335
339
  transform,
336
340
  detail,
341
+ // Canonical detail page for a list card's href (page:<stable_id>).
342
+ detailPage,
337
343
  // Query operators
338
344
  where,
339
345
  limit,
@@ -1,12 +1,20 @@
1
+ // Page-visibility helpers — the two orthogonal axes a page carries:
2
+ // • Reachability — `hidden: bool`. `true` = excluded from the PUBLISHED output
3
+ // entirely (see `dropUnpublishedPages`). Still previewable in `uniweb dev`.
4
+ // • Nav placement — `hideIn: string[]`. Which nav areas suppress the page while
5
+ // it IS routed (see `normalizeHideIn`). `['*']` = suppressed from every area.
6
+
1
7
  /**
2
8
  * Normalize a page/folder config's navigation visibility into a `hideIn` array —
3
9
  * the list of named nav areas a page is suppressed from (layout-area names like
4
10
  * 'header', 'footer', or any foundation-declared area). The canonical form behind
5
11
  * the runtime `hideIn`, the sync `hide_in` field, and `getPageHierarchy({ for })`.
12
+ * The sentinel `'*'` means "suppressed from every nav area" (still routed) — it is
13
+ * a normal array element, interpreted by the runtime, not special-cased here.
6
14
  *
7
15
  * Reads the canonical `hideIn` (an array, or a single string for convenience) and
8
16
  * folds in the legacy `hideInHeader` / `hideInFooter` booleans for back-compat.
9
- * Deduped; declaration order preserved. `hidden` (all-nav exclusion) is separate.
17
+ * Deduped; declaration order preserved. `hidden` (reachability) is a separate axis.
10
18
  *
11
19
  * @param {object} config - a parsed page.yml / folder.yml config (or page data)
12
20
  * @returns {string[]}
@@ -27,3 +35,35 @@ export function normalizeHideIn(config = {}) {
27
35
  if (config.hideInFooter) add('footer')
28
36
  return out
29
37
  }
38
+
39
+ /**
40
+ * Drop unpublished pages — the reachability axis. A page with `hidden: true` is
41
+ * excluded from the PUBLISHED output entirely, and the exclusion CASCADES to its
42
+ * whole subtree: drafting a container drafts the branch. Applied only on the
43
+ * published build paths (`uniweb build` / link-mode deploy); `uniweb dev` keeps
44
+ * hidden pages so in-progress work stays previewable by direct URL.
45
+ *
46
+ * Cascade is resolved via the parent-route chain (the same `parent` route strings
47
+ * the hierarchy is built from), so no surviving page is ever left pointing at a
48
+ * pruned parent — this is what avoids orphaned routes / dangling parent references
49
+ * that a per-node drop would create.
50
+ *
51
+ * @param {Array<object>} pages - collected page data (each with `route`, `parent`)
52
+ * @returns {Array<object>} pages with hidden pages and their descendants removed
53
+ */
54
+ export function dropUnpublishedPages(pages) {
55
+ if (!Array.isArray(pages) || pages.length === 0) return pages
56
+ const byRoute = new Map(pages.map((p) => [p.route, p]))
57
+ const cache = new Map()
58
+ const isUnpublished = (page, seen = new Set()) => {
59
+ if (!page) return false
60
+ if (cache.has(page.route)) return cache.get(page.route)
61
+ if (seen.has(page.route)) return false // defensive cycle guard (trees don't cycle)
62
+ seen.add(page.route)
63
+ const parent = page.parent ? byRoute.get(page.parent) : null
64
+ const result = page.hidden ? true : (parent ? isUnpublished(parent, seen) : false)
65
+ cache.set(page.route, result)
66
+ return result
67
+ }
68
+ return pages.filter((p) => !isUnpublished(p))
69
+ }
@@ -582,7 +582,9 @@ export function siteContentPlugin(options = {}) {
582
582
  async buildStart() {
583
583
  // Collect content at build start
584
584
  try {
585
- siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
585
+ // dropUnpublished only on a production build — in dev (serve) hidden
586
+ // pages stay in the graph so in-progress drafts remain previewable.
587
+ siteContent = await collectSiteContent(resolvedSitePath, { foundationPath, dropUnpublished: isProduction })
586
588
  headHtml = await loadHeadHtml()
587
589
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
588
590