@uniweb/build 0.14.40 → 0.15.1

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.40",
3
+ "version": "0.15.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,12 +59,12 @@
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.7",
63
- "@uniweb/theming": "0.1.12"
62
+ "@uniweb/theming": "0.1.12",
63
+ "@uniweb/content-writer": "0.2.7"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/content-reader": "1.1.14",
67
- "@uniweb/runtime": "0.8.32",
66
+ "@uniweb/runtime": "0.8.33",
67
+ "@uniweb/content-reader": "1.1.15",
68
68
  "@uniweb/schemas": "0.2.4"
69
69
  },
70
70
  "peerDependencies": {
@@ -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.26"
77
+ "@uniweb/core": "0.7.27"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -28,7 +28,6 @@ export {
28
28
  applySort,
29
29
  applyPostProcessing,
30
30
  mergeDataIntoContent,
31
- singularize,
32
31
  } from '../site/data-fetcher.js'
33
32
 
34
33
  // Cross-reference registry moved out of build into kit
@@ -98,7 +98,7 @@ export async function buildSiteData({
98
98
  // No vite needed — collectSiteContent is a plain async function.
99
99
  // dropUnpublished: link mode is always a published deploy — prune hidden
100
100
  // pages + their subtree so drafts never reach the served site.
101
- let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true, base: basePath })
101
+ let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true, base: basePath, strict: true })
102
102
 
103
103
  // 2. Compile content collections (file-based markdown/yaml/json).
104
104
  // `writeCollectionFiles` lands them under `<siteRoot>/public/data/`;
@@ -25,12 +25,12 @@
25
25
 
26
26
  import { readFile, readdir, stat } from 'node:fs/promises'
27
27
  import { join, parse, resolve, sep } from 'node:path'
28
- import { existsSync, statSync, realpathSync } from 'node:fs'
28
+ import { existsSync, statSync, realpathSync, readdirSync } 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
32
  import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
33
- import { parseFetchConfig, singularize } from './data-fetcher.js'
33
+ import { parseFetchConfig } from './data-fetcher.js'
34
34
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
35
35
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
36
36
 
@@ -267,10 +267,18 @@ function stripAtPrefix(filename) {
267
267
 
268
268
  /**
269
269
  * Check if a folder should be ignored.
270
- * Excludes folders starting with _ (drafts/private).
270
+ *
271
+ * Excludes folders starting with `_` (drafts/private) and with `.` (hidden).
272
+ *
273
+ * Hidden folders matter more than they look. A site's own `pages/` never holds
274
+ * one, but a mount target routinely does: point `paths:` at a directory that is
275
+ * also a git working tree — a sibling clone, which the docs suggest — and `.git`
276
+ * is a directory sitting right next to the content. Walked as content it
277
+ * contributed hundreds of routes. (A submodule hides this: there, `.git` is a
278
+ * file, so only the plain-clone case ever showed it.)
271
279
  */
272
280
  function isIgnoredFolder(name) {
273
- return name.startsWith('_')
281
+ return name.startsWith('_') || name.startsWith('.')
274
282
  }
275
283
 
276
284
  /**
@@ -339,6 +347,35 @@ async function readFolderConfig(dirPath, inheritedMode) {
339
347
  return { config: {}, mode: inheritedMode, source: 'inherited' }
340
348
  }
341
349
 
350
+ /**
351
+ * Whether a mount target holds anything this collector would read.
352
+ *
353
+ * A mount declares "pages come from here." An existing, readable directory is
354
+ * not the same claim — an uninitialized git submodule is exactly that, and it
355
+ * is the state a plain `git clone` leaves one in. So look for content the walk
356
+ * would actually pick up: a folder to recurse into, a markdown file, or a
357
+ * folder/page config. README.md deliberately does not count; isMarkdownFile
358
+ * excludes it as repo documentation.
359
+ *
360
+ * @param {string} dirPath - Absolute path to the mount target
361
+ * @returns {boolean}
362
+ */
363
+ function mountHasContent(dirPath) {
364
+ let entries
365
+ try {
366
+ entries = readdirSync(dirPath, { withFileTypes: true })
367
+ } catch {
368
+ return false
369
+ }
370
+
371
+ return entries.some(entry => {
372
+ const { name } = entry
373
+ if (name.startsWith('.') || isIgnoredFolder(name)) return false
374
+ if (entry.isDirectory()) return true
375
+ return isMarkdownFile(name) || name === 'folder.yml' || name === 'page.yml'
376
+ })
377
+ }
378
+
342
379
  /**
343
380
  * Extract page mounts from site.yml paths: config.
344
381
  *
@@ -348,9 +385,14 @@ async function readFolderConfig(dirPath, inheritedMode) {
348
385
  * @param {Object} pathsConfig - The paths: object from site.yml
349
386
  * @param {string} sitePath - Absolute path to the site directory
350
387
  * @param {string} pagesPath - Resolved absolute path to the pages directory
388
+ * @param {Object} [options]
389
+ * @param {boolean} [options.strict=false] - Treat a mount that contributes no
390
+ * pages as an error rather than a warning. Dev is forgiving — an empty folder
391
+ * is normal the moment an author creates one. A build is not: shipping a route
392
+ * with nothing under it is never what was meant.
351
393
  * @returns {Map<string, string>|null} Route segment → canonical absolute path, or null
352
394
  */
353
- function resolveMounts(pathsConfig, sitePath, pagesPath) {
395
+ function resolveMounts(pathsConfig, sitePath, pagesPath, { strict = false } = {}) {
354
396
  if (!pathsConfig || typeof pathsConfig !== 'object') return null
355
397
 
356
398
  // Extract entries with "pages/" prefix (e.g., "pages/docs": "../../../docs")
@@ -390,6 +432,22 @@ function resolveMounts(pathsConfig, sitePath, pagesPath) {
390
432
  )
391
433
  }
392
434
 
435
+ // Existing, readable, and empty — which is what a mount looks like when its
436
+ // git submodule was never fetched. Every check above passes and the route
437
+ // builds with nothing under it, so say so rather than shipping the silence.
438
+ if (!mountHasContent(absolutePath)) {
439
+ const detail =
440
+ `External pages path is empty: ${absolutePath}\n` +
441
+ ` Declared in site.yml: pages/${routeSegment}: ${relativePath}\n` +
442
+ ` The "${routeSegment}" route will have no pages under it.\n` +
443
+ ` If this is a git submodule: git submodule update --init --recursive`
444
+
445
+ if (strict) {
446
+ throw new Error(`[content-collector] ${detail}`)
447
+ }
448
+ console.warn(`[content-collector] ${detail}`)
449
+ }
450
+
393
451
  const canonical = realpathSync(absolutePath)
394
452
 
395
453
  // Reject node_modules
@@ -1373,28 +1431,61 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1373
1431
  let notFound = null
1374
1432
  const versionedScopes = new Map() // scope route → versionMeta
1375
1433
 
1376
- // First pass: discover all page folders and read their config
1377
- const pageFolders = []
1434
+ // First pass: discover all page folders and read their config.
1435
+ //
1436
+ // A route can be backed by a local directory, by a mount, or by both. When
1437
+ // both, the two configs LAYER rather than one replacing the other — the same
1438
+ // rule as params over meta defaults, or page over folder config. The division
1439
+ // it encodes: the mounted repo declares what its content is (ordering, title,
1440
+ // how it nests), the local folder declares how this site presents it (layout,
1441
+ // SEO, menu visibility). A local folder used to win outright, and since a
1442
+ // local folder is the only place `layout:` can be declared for a mounted
1443
+ // route, an author had to choose between a layout and the mounted repo's own
1444
+ // config. There was no way to see which one had been dropped.
1445
+ const localDirs = new Set()
1378
1446
  for (const entry of entries) {
1379
1447
  if (isIgnoredFolder(entry)) continue // Skip _prefixed folders
1380
- const entryPath = join(dirPath, entry)
1381
- const stats = await stat(entryPath)
1382
- if (!stats.isDirectory()) continue
1448
+ const stats = await stat(join(dirPath, entry))
1449
+ if (stats.isDirectory()) localDirs.add(entry)
1450
+ }
1451
+
1452
+ const routeNames = new Set([...localDirs, ...(mounts?.keys() ?? [])])
1383
1453
 
1384
- // Read folder.yml or page.yml to determine mode and get config
1385
- const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
1386
- const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1454
+ const pageFolders = []
1455
+ for (const name of routeNames) {
1456
+ const mountPath = mounts?.get(name) ?? null
1457
+ const localPath = localDirs.has(name) ? join(dirPath, name) : null
1458
+
1459
+ // The content's declaration about itself, then the site's about presenting it.
1460
+ const mounted = mountPath ? await readFolderConfig(mountPath, 'pages') : null
1461
+ const local = localPath ? await readFolderConfig(localPath, contentMode) : null
1462
+
1463
+ const dirConfig = mounted
1464
+ ? { title: mounted.config.title || name, ...mounted.config, ...local?.config }
1465
+ : local.config
1466
+
1467
+ // Mode is not a config key — it comes from which config file exists, so it
1468
+ // cannot be spread. An explicit local declaration wins (a `page.yml` stub
1469
+ // with its own markdown is a real landing page above mounted children);
1470
+ // otherwise the mount decides, because it owns the content being read.
1471
+ const dirMode = local && local.source !== 'inherited'
1472
+ ? local.mode
1473
+ : mounted?.mode ?? local?.mode ?? contentMode
1387
1474
 
1388
- // Extract layout name from folder config (folder.yml layout: or page.yml layout:)
1389
1475
  const folderLayout = typeof dirConfig.layout === 'string' ? dirConfig.layout
1390
1476
  : dirConfig.layout?.name || null
1391
1477
 
1392
1478
  pageFolders.push({
1393
- name: entry,
1394
- path: entryPath,
1395
- order: numericOrder,
1479
+ name,
1480
+ path: localPath ?? mountPath,
1481
+ order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined,
1396
1482
  dirConfig,
1397
1483
  dirMode,
1484
+ // The mode the mounted subtree is walked in belongs to the mount: it is
1485
+ // the mount's tree. Without this a `page.yml` stub silently imposed page
1486
+ // mode on a mounted folder of pages, and only every mounted subfolder
1487
+ // carrying its own folder.yml kept that from collapsing them into one page.
1488
+ mountedContentMode: mounted?.mode ?? null,
1398
1489
  childOrderConfig: {
1399
1490
  pages: dirConfig.pages,
1400
1491
  index: dirConfig.index
@@ -1403,29 +1494,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1403
1494
  })
1404
1495
  }
1405
1496
 
1406
- // Inject virtual entries for mounts without physical directories
1407
- if (mounts) {
1408
- for (const [routeSegment, mountPath] of mounts) {
1409
- if (!pageFolders.some(f => f.name === routeSegment)) {
1410
- const { config: mountConfig } = await readFolderConfig(mountPath, 'pages')
1411
- const mountLayout = typeof mountConfig.layout === 'string' ? mountConfig.layout
1412
- : mountConfig.layout?.name || null
1413
- pageFolders.push({
1414
- name: routeSegment,
1415
- path: mountPath,
1416
- order: typeof mountConfig.order === 'number' ? mountConfig.order : undefined,
1417
- dirConfig: { title: mountConfig.title || routeSegment, ...mountConfig },
1418
- dirMode: 'pages',
1419
- childOrderConfig: {
1420
- pages: mountConfig.pages,
1421
- index: mountConfig.index
1422
- },
1423
- childLayoutName: mountLayout
1424
- })
1425
- }
1426
- }
1427
- }
1428
-
1429
1497
  // Sort page folders by order (ascending), then alphabetically
1430
1498
  // Pages without explicit order come after ordered pages (order ?? Infinity)
1431
1499
  pageFolders.sort((a, b) => {
@@ -1565,7 +1633,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1565
1633
 
1566
1634
  // Process subdirectories
1567
1635
  for (const folder of orderedFolders) {
1568
- const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig, childLayoutName } = folder
1636
+ const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayoutName } = folder
1569
1637
  const isIndex = entry === indexName
1570
1638
  const effectiveLayout = childLayoutName || parentLayoutName
1571
1639
 
@@ -1590,11 +1658,14 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1590
1658
 
1591
1659
  pages.push(page)
1592
1660
 
1593
- // Recurse into subdirectories (page mode)
1661
+ // Recurse into subdirectories (page mode). When the children come from
1662
+ // a mount, they are walked in the mount's own mode — it is the mount's
1663
+ // tree, and its root config is what declared how its content is shaped.
1594
1664
  const childDirPath = mounts?.get(entry) || entryPath
1595
1665
  const childParentRoute = isIndex ? parentRoute : page.route
1596
1666
  const childFetch = page.fetch || parentFetch
1597
- const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections', null, effectiveLayout)
1667
+ const childContentMode = mountedContentMode ?? 'sections'
1668
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, childContentMode, null, effectiveLayout)
1598
1669
  pages.push(...subResult.pages)
1599
1670
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1600
1671
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1693,7 +1764,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1693
1764
 
1694
1765
  // Second pass: process each page folder
1695
1766
  for (const folder of orderedFolders) {
1696
- const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig, childLayoutName } = folder
1767
+ const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayoutName } = folder
1697
1768
  const isIndex = entry === indexPageName
1698
1769
  const effectiveLayout = childLayoutName || parentLayoutName
1699
1770
 
@@ -1782,14 +1853,17 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1782
1853
  pages.push(page)
1783
1854
  }
1784
1855
 
1785
- // Recursively process subdirectories
1856
+ // Recursively process subdirectories. Children coming from a mount are
1857
+ // walked in the mount's own mode — its root config declared how its
1858
+ // content is shaped, and this local stub holds none of that content.
1786
1859
  {
1787
1860
  const childDirPath = mounts?.get(entry) || entryPath
1788
1861
  const childParentRoute = isIndex
1789
1862
  ? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
1790
1863
  : page.route
1791
1864
  const childFetch = page.fetch || parentFetch
1792
- const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode, null, effectiveLayout)
1865
+ const childContentMode = mountedContentMode ?? dirMode
1866
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, childContentMode, null, effectiveLayout)
1793
1867
  pages.push(...subResult.pages)
1794
1868
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1795
1869
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -2011,10 +2085,11 @@ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
2011
2085
  * @param {string} options.foundationPath - Path to foundation directory (for theme vars)
2012
2086
  * @param {string} [options.configFile='site.yml'] - Name of the top-level config file inside sitePath. Defaults to 'site.yml'. Document tools (unipress) pass 'document.yml'.
2013
2087
  * @param {'document'|'site'} [options.profile] - Explicit content profile, authoritative over the filename. Lets a tool that knows its context (unipress → 'document') read an arbitrarily named config with the right directory/mode/ordering. Falls back to the configFile-derived profile when omitted.
2088
+ * @param {boolean} [options.strict=false] - Fail on content problems that would ship broken output rather than warning about them. A production build passes true; dev leaves it false, because a folder an author just created is empty for a moment and that is not an error.
2014
2089
  * @returns {Promise<Object>} Site content object with assets manifest
2015
2090
  */
2016
2091
  export async function collectSiteContent(sitePath, options = {}) {
2017
- const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false, base = '/' } = options
2092
+ const { foundationPath, configFile = 'site.yml', profile: profileName, dropUnpublished = false, base = '/', strict = false } = options
2018
2093
 
2019
2094
  // Read site config and raw theme config
2020
2095
  const siteConfig = await readYamlFile(join(sitePath, configFile))
@@ -2054,7 +2129,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2054
2129
  : profileDefault
2055
2130
  }
2056
2131
 
2057
- const mounts = resolveMounts(siteConfig.paths, sitePath, pagesPath)
2132
+ const mounts = resolveMounts(siteConfig.paths, sitePath, pagesPath, { strict })
2058
2133
 
2059
2134
  const layoutPath = siteConfig.paths?.layout
2060
2135
  ? resolve(sitePath, siteConfig.paths.layout)
@@ -504,58 +504,6 @@ export function mergeDataIntoContent(content, fetchedData, schema, merge = false
504
504
  return result
505
505
  }
506
506
 
507
- /**
508
- * Convert a plural schema name to singular
509
- * Used for dynamic routes where the parent has "articles" and
510
- * each child page gets the singular "article" for the current item
511
- *
512
- * @param {string} name - Plural name (e.g., 'articles', 'posts', 'people')
513
- * @returns {string} Singular name (e.g., 'article', 'post', 'person')
514
- *
515
- * @example
516
- * singularize('articles') // 'article'
517
- * singularize('posts') // 'post'
518
- * singularize('people') // 'person'
519
- * singularize('categories') // 'category'
520
- */
521
- export function singularize(name) {
522
- if (!name) return name
523
-
524
- // Handle common irregular plurals
525
- const irregulars = {
526
- people: 'person',
527
- children: 'child',
528
- men: 'man',
529
- women: 'woman',
530
- feet: 'foot',
531
- teeth: 'tooth',
532
- mice: 'mouse',
533
- geese: 'goose',
534
- }
535
-
536
- if (irregulars[name]) return irregulars[name]
537
-
538
- // Standard rules (in order of specificity)
539
- if (name.endsWith('ies')) {
540
- // categories -> category
541
- return name.slice(0, -3) + 'y'
542
- }
543
- if (name.endsWith('ves')) {
544
- // leaves -> leaf
545
- return name.slice(0, -3) + 'f'
546
- }
547
- if (name.endsWith('es') && (name.endsWith('shes') || name.endsWith('ches') || name.endsWith('xes') || name.endsWith('sses') || name.endsWith('zes'))) {
548
- // boxes -> box, watches -> watch
549
- return name.slice(0, -2)
550
- }
551
- if (name.endsWith('s') && !name.endsWith('ss')) {
552
- // articles -> article
553
- return name.slice(0, -1)
554
- }
555
-
556
- return name
557
- }
558
-
559
507
  /**
560
508
  * Execute multiple fetch operations in parallel
561
509
  *
package/src/site/index.js CHANGED
@@ -45,8 +45,7 @@ export {
45
45
  applyFilter,
46
46
  applySort,
47
47
  applyPostProcessing,
48
- mergeDataIntoContent,
49
- singularize
48
+ mergeDataIntoContent
50
49
  } from './data-fetcher.js'
51
50
  export { loadDeployYml, resolveTarget } from './deploy-config.js'
52
51
  export { recordLastDeploy, recordTarget } from './deploy-config-writer.js'
@@ -639,7 +639,9 @@ export function siteContentPlugin(options = {}) {
639
639
  try {
640
640
  // dropUnpublished only on a production build — in dev (serve) hidden
641
641
  // pages stay in the graph so in-progress drafts remain previewable.
642
- siteContent = await collectSiteContent(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath })
642
+ // strict on a production build: a mount that contributes no pages is a
643
+ // warning while you author and a broken deploy once you ship.
644
+ siteContent = await collectSiteContent(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction })
643
645
  headHtml = await loadHeadHtml()
644
646
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
645
647