@uniweb/build 0.14.29 → 0.14.31

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.29",
3
+ "version": "0.14.31",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,8 +64,8 @@
64
64
  },
65
65
  "optionalDependencies": {
66
66
  "@uniweb/runtime": "0.8.26",
67
- "@uniweb/content-reader": "1.1.12",
68
- "@uniweb/schemas": "0.2.4"
67
+ "@uniweb/schemas": "0.2.4",
68
+ "@uniweb/content-reader": "1.1.12"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/dev/plugin.js CHANGED
@@ -23,10 +23,59 @@
23
23
  import { resolve, join } from 'node:path'
24
24
  import { watch } from 'node:fs'
25
25
  import { readFile } from 'node:fs/promises'
26
- import { existsSync } from 'node:fs'
26
+ import { existsSync, readdirSync } from 'node:fs'
27
27
  import { build } from 'vite'
28
28
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
29
29
 
30
+ /** Directories that never hold foundation source and must never be walked. */
31
+ const UNWATCHABLE_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage'])
32
+
33
+ /**
34
+ * Resolve what to watch for a foundation's source changes.
35
+ *
36
+ * Returns the source root as a NON-recursive target (root-level main.js,
37
+ * styles.css, …) plus each source subdirectory as a recursive target.
38
+ *
39
+ * Enumerating subdirectories rather than naming them keeps this correct across
40
+ * every foundation layout — `sections/`, `components/`, `utils/`, `layouts/`,
41
+ * whatever extra section paths `defineFoundationConfig({ sections })` declares,
42
+ * and whatever a given project happens to call its folders.
43
+ *
44
+ * Known limitation: a top-level directory created after the server starts is
45
+ * not watched recursively until restart. The non-recursive root watch still
46
+ * sees it appear, so a rebuild fires; only per-file events inside it are
47
+ * missed. This matches the existing directory-rename limitation.
48
+ *
49
+ * Watching the source root recursively is not an option: under the flat layout
50
+ * (`main: "./_entry.generated.js"`) that root is the foundation *package* root,
51
+ * so a recursive watch descends into `node_modules/`. On Linux — including
52
+ * WSL2 — Node implements recursive fs.watch as one inotify watch per
53
+ * subdirectory, which exhausts `fs.inotify.max_user_watches` on startup and
54
+ * fails with ENOSPC. macOS hides the bug entirely: FSEvents makes a recursive
55
+ * watch a single handle regardless of tree size.
56
+ *
57
+ * @param {string} srcPath - Foundation source directory (absolute)
58
+ * @returns {Array<{path: string, recursive: boolean}>}
59
+ */
60
+ function resolveWatchTargets(srcPath) {
61
+ const targets = [{ path: srcPath, recursive: false }]
62
+
63
+ let entries = []
64
+ try {
65
+ entries = readdirSync(srcPath, { withFileTypes: true })
66
+ } catch {
67
+ return targets
68
+ }
69
+
70
+ for (const entry of entries) {
71
+ if (!entry.isDirectory()) continue
72
+ if (entry.name.startsWith('.') || UNWATCHABLE_DIRS.has(entry.name)) continue
73
+ targets.push({ path: join(srcPath, entry.name), recursive: true })
74
+ }
75
+
76
+ return targets
77
+ }
78
+
30
79
  /**
31
80
  * Create the foundation dev plugin
32
81
  *
@@ -180,28 +229,37 @@ export function foundationDevPlugin(options = {}) {
180
229
  }, 200)
181
230
  }
182
231
 
183
- try {
184
- watcher = watch(srcPath, { recursive: true }, (eventType, filename) => {
185
- // Ignore generated files (build output triggers entry regeneration)
186
- if (filename && filename.includes('_entry.generated')) return
187
-
188
- // Only rebuild for source file changes
189
- if (
190
- filename &&
191
- (filename.endsWith('.js') ||
192
- filename.endsWith('.jsx') ||
193
- filename.endsWith('.ts') ||
194
- filename.endsWith('.tsx') ||
195
- filename.endsWith('.css') ||
196
- filename.endsWith('.svg'))
197
- ) {
198
- console.log(`[foundation] ${filename} changed`)
199
- scheduleRebuild()
200
- }
201
- })
232
+ const onChange = (eventType, filename) => {
233
+ // Ignore generated files (build output triggers entry regeneration)
234
+ if (filename && filename.includes('_entry.generated')) return
235
+
236
+ // Only rebuild for source file changes
237
+ if (
238
+ filename &&
239
+ (filename.endsWith('.js') ||
240
+ filename.endsWith('.jsx') ||
241
+ filename.endsWith('.ts') ||
242
+ filename.endsWith('.tsx') ||
243
+ filename.endsWith('.css') ||
244
+ filename.endsWith('.svg'))
245
+ ) {
246
+ console.log(`[foundation] ${filename} changed`)
247
+ scheduleRebuild()
248
+ }
249
+ }
250
+
251
+ const watchers = []
252
+ for (const target of resolveWatchTargets(srcPath)) {
253
+ try {
254
+ watchers.push(watch(target.path, { recursive: target.recursive }, onChange))
255
+ } catch (err) {
256
+ console.warn(`[foundation] Could not watch ${target.path}:`, err.message)
257
+ }
258
+ }
259
+
260
+ if (watchers.length > 0) {
261
+ watcher = { close: () => watchers.forEach(w => w.close()) }
202
262
  console.log(`[foundation] Watching ${srcPath}`)
203
- } catch (err) {
204
- console.warn(`[foundation] Could not watch source:`, err.message)
205
263
  }
206
264
  }
207
265
  },
@@ -22,7 +22,12 @@
22
22
  import { writeFile, readFile, mkdir } from 'node:fs/promises'
23
23
  import { existsSync } from 'node:fs'
24
24
  import { join, dirname } from 'node:path'
25
- import { discoverComponents, discoverLayoutsInPath } from './schema.js'
25
+ import {
26
+ discoverComponents,
27
+ discoverLayoutsInPath,
28
+ DEFAULT_SECTION_PATHS,
29
+ LAYOUTS_PATH
30
+ } from './schema.js'
26
31
  import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas } from './runtime-schema.js'
27
32
  import { collectSchemaRefs, buildDataSchemaMap } from './resolve-data-schema.js'
28
33
 
@@ -361,6 +366,53 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
361
366
  }
362
367
  }
363
368
 
369
+ /** Normalize native path separators so matching is platform-independent. */
370
+ const toPosix = path => path.replace(/\\/g, '/')
371
+
372
+ /**
373
+ * Structural paths worth watching, given a foundation source root.
374
+ *
375
+ * Lives next to `shouldRegenerateForFile` so the watch surface and the match
376
+ * predicate stay in sync — anything this returns is a path that predicate can
377
+ * match, and nothing it matches lies outside these paths.
378
+ *
379
+ * Derived from the same discovery constants `generateEntryPoint` scans, and
380
+ * takes the same `sectionPaths` override, so a foundation that declares extra
381
+ * search paths via `defineFoundationConfig({ sections })` is watched wherever
382
+ * its sections actually live. Foundation folder layout is free-form — `src/`,
383
+ * `foundations/blog/`, `marketing/src/` — but every path here is relative to
384
+ * the resolved source root, so layout does not matter.
385
+ *
386
+ * Deliberately does NOT include the source root itself. Under the flat layout
387
+ * (`main: "./_entry.generated.js"`) the source root IS the foundation package
388
+ * root, so handing it to a watcher pulls in `node_modules/`, `dist/` and
389
+ * `.git/` — thousands of directories, and on Linux one inotify watch each.
390
+ *
391
+ * @param {string} srcDir - Foundation source directory (absolute)
392
+ * @param {Object} [options]
393
+ * @param {string[]} [options.sectionPaths] - Section search paths (relative to srcDir)
394
+ * @returns {string[]} Absolute paths to hand to a watcher
395
+ */
396
+ export function getStructuralWatchPaths(srcDir, options = {}) {
397
+ const { sectionPaths = DEFAULT_SECTION_PATHS } = options
398
+
399
+ const rootFiles = [
400
+ 'meta.js',
401
+ 'main.js',
402
+ 'main.jsx',
403
+ 'foundation.js',
404
+ 'foundation.jsx',
405
+ 'styles.css',
406
+ 'index.css'
407
+ ]
408
+
409
+ return [
410
+ ...sectionPaths.map(path => join(srcDir, path)),
411
+ join(srcDir, LAYOUTS_PATH),
412
+ ...rootFiles.map(file => join(srcDir, file))
413
+ ]
414
+ }
415
+
364
416
  /**
365
417
  * Check if a file change should trigger entry point regeneration.
366
418
  *
@@ -370,14 +422,28 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
370
422
  * The content-comparison guard in generateEntryPoint() makes false positives
371
423
  * cheap (discovery runs but no write), so we err on the side of regenerating.
372
424
  *
425
+ * The bare-file and entry-file rules below are scoped to the primary `sections`
426
+ * path on purpose: that is the only path where relaxed discovery applies.
427
+ * Additional paths declared via `defineFoundationConfig({ sections })` use
428
+ * strict discovery, where a section is only registered once it has a `meta.js`
429
+ * — and the "meta.js anywhere" rule already covers those. Widening the bare-file
430
+ * rule to every configured path would fire on files discovery ignores.
431
+ *
373
432
  * @param {string} file - Absolute path of the changed file
374
433
  * @param {string} srcDir - Foundation source directory (absolute)
375
434
  * @returns {string|null} Reason string if regeneration needed, null otherwise
376
435
  */
377
436
  export function shouldRegenerateForFile(file, srcDir) {
378
- if (!file.startsWith(srcDir + '/')) return null
437
+ // Both sides arrive with native separators — chokidar normalizes the paths it
438
+ // emits with path.normalize() on Windows, and srcDir comes from path.resolve().
439
+ // Comparing against a hardcoded '/' silently matched nothing on Windows, so
440
+ // entry regeneration never fired there and new sections needed a dev restart.
441
+ const normalized = toPosix(file)
442
+ const base = toPosix(srcDir).replace(/\/+$/, '')
443
+
444
+ if (!normalized.startsWith(base + '/')) return null
379
445
 
380
- const rel = file.slice(srcDir.length + 1)
446
+ const rel = normalized.slice(base.length + 1)
381
447
 
382
448
  // meta.js anywhere — affects runtime metadata
383
449
  if (rel.endsWith('/meta.js') || rel === 'meta.js') {
@@ -22,15 +22,38 @@
22
22
  * @param {boolean} [opts.checkout] — Include the checkout step (default true).
23
23
  * @returns {string} YAML fragment, no trailing newline.
24
24
  */
25
+ /**
26
+ * Fallback pnpm major, used only when a caller omits `pnpmVersion`.
27
+ *
28
+ * The CLI is the authority — it resolves the major from the project's
29
+ * `packageManager` field (see `versions.js::resolveCiPnpmVersion`) and
30
+ * always passes it, so this value does not apply on the normal path. It
31
+ * exists for direct API and test callers.
32
+ *
33
+ * `@uniweb/build` cannot import the CLI's constant (the CLI depends on
34
+ * this package, not the reverse), so this is a deliberate duplicate.
35
+ * Keeping ONE copy here — rather than one per adapter — is the point:
36
+ * the adapters previously each defaulted to '11' and drifted out of sync
37
+ * with the CLI when it moved to '10', leaving five stale fallbacks that
38
+ * would have silently generated an uninstallable workflow for any caller
39
+ * that omitted the argument.
40
+ */
41
+ const FALLBACK_PNPM_VERSION = '10'
42
+
25
43
  export function setupSteps({
26
44
  packageManager = 'pnpm',
27
45
  nodeVersion = '20',
28
- pnpmVersion = '11',
46
+ pnpmVersion = FALLBACK_PNPM_VERSION,
29
47
  checkout = true,
30
48
  } = {}) {
31
49
  const lines = []
32
50
  if (checkout) lines.push(' - uses: actions/checkout@v4')
33
51
 
52
+ // The install command has to match the repo's lockfile: `npm ci` fails
53
+ // outright without package-lock.json, and so does
54
+ // `pnpm install --frozen-lockfile` without pnpm-lock.yaml. Callers must
55
+ // pass the WORKSPACE's package manager (detected from its lockfile),
56
+ // not the one that happened to launch the CLI.
34
57
  if (packageManager === 'pnpm') {
35
58
  lines.push(' - uses: pnpm/action-setup@v4')
36
59
  lines.push(' with:')
@@ -40,6 +63,13 @@ export function setupSteps({
40
63
  lines.push(` node-version: '${nodeVersion}'`)
41
64
  lines.push(' cache: pnpm')
42
65
  lines.push(' - run: pnpm install --frozen-lockfile')
66
+ } else if (packageManager === 'yarn') {
67
+ lines.push(' - uses: actions/setup-node@v4')
68
+ lines.push(' with:')
69
+ lines.push(` node-version: '${nodeVersion}'`)
70
+ lines.push(' cache: yarn')
71
+ // Accepted by Yarn 1 and aliased to --immutable by Berry.
72
+ lines.push(' - run: yarn install --frozen-lockfile')
43
73
  } else {
44
74
  lines.push(' - uses: actions/setup-node@v4')
45
75
  lines.push(' with:')
@@ -59,7 +89,10 @@ export function setupSteps({
59
89
  * @returns {string}
60
90
  */
61
91
  export function uniwebBuildCommand({ packageManager = 'pnpm', host }) {
62
- const runner = packageManager === 'pnpm' ? 'pnpm exec' : 'npx'
92
+ const runner =
93
+ packageManager === 'pnpm' ? 'pnpm exec'
94
+ : packageManager === 'yarn' ? 'yarn exec'
95
+ : 'npx'
63
96
  return `${runner} uniweb build --host=${host}`
64
97
  }
65
98
 
@@ -201,7 +201,7 @@ async function initCi({
201
201
  site,
202
202
  packageManager = 'pnpm',
203
203
  nodeVersion = '20',
204
- pnpmVersion = '11',
204
+ pnpmVersion,
205
205
  domain = null,
206
206
  projectName = null,
207
207
  previews = true,
@@ -281,7 +281,7 @@ const adapter = {
281
281
  target = 'site',
282
282
  packageManager = 'pnpm',
283
283
  nodeVersion = '20',
284
- pnpmVersion = '11',
284
+ pnpmVersion,
285
285
  domain = null,
286
286
  }) {
287
287
  if (target === 'foundation') {
@@ -140,7 +140,7 @@ async function initCi({
140
140
  site,
141
141
  packageManager = 'pnpm',
142
142
  nodeVersion = '20',
143
- pnpmVersion = '11',
143
+ pnpmVersion,
144
144
  domain = null,
145
145
  previews = true,
146
146
  }) {
@@ -122,7 +122,7 @@ async function initCi({
122
122
  site,
123
123
  packageManager = 'pnpm',
124
124
  nodeVersion = '20',
125
- pnpmVersion = '11',
125
+ pnpmVersion,
126
126
  domain = null,
127
127
  previews = true,
128
128
  }) {
package/src/schema.js CHANGED
@@ -26,8 +26,10 @@ const META_FILE_NAME = 'meta.js'
26
26
  // Whichever exists at the source root is loaded.
27
27
  const FOUNDATION_FILE_NAMES = ['main.js', 'foundation.js']
28
28
 
29
- // Default paths to scan for section types (relative to srcDir)
30
- const DEFAULT_SECTION_PATHS = ['sections']
29
+ // Default paths to scan for section types (relative to srcDir).
30
+ // Exported so dev-server watchers can derive their watch surface from the same
31
+ // value discovery uses, instead of re-typing the literal and drifting from it.
32
+ export const DEFAULT_SECTION_PATHS = ['sections']
31
33
 
32
34
  // Extensions recognized as component entry files
33
35
  const COMPONENT_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts'])
@@ -36,7 +38,7 @@ const COMPONENT_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts'])
36
38
  const SECTIONS_PATH = 'sections'
37
39
 
38
40
  // The layouts path where layout components are discovered
39
- const LAYOUTS_PATH = 'layouts'
41
+ export const LAYOUTS_PATH = 'layouts'
40
42
 
41
43
  /**
42
44
  * Load a meta.js file via dynamic import
@@ -23,7 +23,11 @@
23
23
  import { existsSync, readFileSync } from 'node:fs'
24
24
  import { resolve, dirname, join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
- import { generateEntryPoint, shouldRegenerateForFile } from '../generate-entry.js'
26
+ import {
27
+ generateEntryPoint,
28
+ shouldRegenerateForFile,
29
+ getStructuralWatchPaths
30
+ } from '../generate-entry.js'
27
31
  import { importMapPlugin } from '../import-map-plugin.js'
28
32
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
29
33
 
@@ -360,11 +364,16 @@ export async function defineSiteConfig(options = {}) {
360
364
  },
361
365
 
362
366
  configureServer(server) {
363
- // Watch foundation src for structural changes that affect the entry
367
+ // Watch foundation src for structural changes that affect the entry.
368
+ // Add the structural paths individually rather than the source root:
369
+ // under the flat layout that root is the foundation *package* root, so
370
+ // adding it walks node_modules/, dist/ and .git/. Vite's default ignore
371
+ // list filters those out today, but there is no reason to hand a package
372
+ // root to a watcher and depend on the filter to undo it.
364
373
  const srcDir = resolveFoundationSrcPath(foundationInfo.path)
365
374
  const entryPath = join(srcDir, '_entry.generated.js')
366
375
 
367
- server.watcher.add(srcDir)
376
+ server.watcher.add(getStructuralWatchPaths(srcDir))
368
377
 
369
378
  server.watcher.on('all', async (event, path) => {
370
379
  const reason = shouldRegenerateForFile(path, srcDir)