uniweb 0.12.17 → 0.12.19

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",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,11 +42,11 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "0.7.11",
45
- "@uniweb/runtime": "0.8.13",
46
- "@uniweb/kit": "0.9.11"
45
+ "@uniweb/kit": "0.9.11",
46
+ "@uniweb/runtime": "0.8.13"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.3",
49
+ "@uniweb/build": "0.14.4",
50
50
  "@uniweb/content-reader": "1.1.10",
51
51
  "@uniweb/semantic-parser": "1.1.17"
52
52
  },
@@ -60,5 +60,8 @@
60
60
  "@uniweb/semantic-parser": {
61
61
  "optional": true
62
62
  }
63
+ },
64
+ "scripts": {
65
+ "test": "node --test 'test/**/*.test.js'"
63
66
  }
64
67
  }
@@ -21,10 +21,9 @@ import { scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemp
21
21
  import {
22
22
  readWorkspaceConfig,
23
23
  addWorkspaceGlob,
24
- discoverFoundations,
25
- discoverSites,
26
24
  updateRootScripts,
27
25
  } from '../utils/config.js'
26
+ import { discoverFoundations, discoverSites } from '../utils/discover.js'
28
27
  import { validatePackageName, getExistingPackageNames, resolveUniqueName } from '../utils/names.js'
29
28
  import { findWorkspaceRoot } from '../utils/workspace.js'
30
29
  import { detectPackageManager, filterCmd, installCmd } from '../utils/pm.js'
@@ -45,8 +45,8 @@
45
45
  * --bundle # Full vite pipeline (third-party hosts)
46
46
  */
47
47
 
48
- import { existsSync, readFileSync } from 'node:fs'
49
- import { resolve, join, dirname } from 'node:path'
48
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
49
+ import { resolve, join, dirname, basename } from 'node:path'
50
50
  import { spawn } from 'node:child_process'
51
51
  import { writeFile, mkdir } from 'node:fs/promises'
52
52
  import { createRequire } from 'node:module'
@@ -243,6 +243,118 @@ async function buildFoundation(projectDir, options = {}) {
243
243
  log(` ${colors.bright}uniweb handoff <email>${colors.reset} Hand off a site to a client`)
244
244
  }
245
245
 
246
+ /**
247
+ * Ensure a local foundation's `dist/entry.js` is current.
248
+ *
249
+ * Whenever a build or deploy reads a local foundation from disk — bundle
250
+ * mode (vite imports it, prerender loads it for SSG), or link mode where
251
+ * the foundation is uploaded alongside the site — the foundation must be
252
+ * built and current. Otherwise the verb fails with "Foundation not found
253
+ * at .../dist/entry.js" or silently ships stale artifacts.
254
+ *
255
+ * `buildWorkspace()` already cascades when invoked from a workspace root,
256
+ * but verbs invoked from a site directory (`uniweb build` in `sites/x/`,
257
+ * `uniweb deploy`, `uniweb export`) used to skip the cascade and rely on
258
+ * the user having pre-built the foundation. That broke fresh checkouts
259
+ * where the foundation has never been built locally.
260
+ *
261
+ * This helper is idempotent: when the workspace-root cascade has already
262
+ * run, the freshness check sees a current `dist/entry.js` and returns
263
+ * without rebuilding. So adding the call inside `buildSite()` /
264
+ * `buildSiteLink()` does not double-build under `buildWorkspace()`.
265
+ *
266
+ * Freshness rule: a built artifact (`dist/entry.js` or the legacy
267
+ * `dist/foundation.js`) exists AND its mtime is >= the newest mtime of
268
+ * any tracked source file. Tracked sources: every file under
269
+ * `<foundation>/src/`, plus root-level `package.json`, `foundation.js`,
270
+ * `meta.js` (the structural files that drive entry generation and
271
+ * schema). Build outputs and node_modules are skipped.
272
+ *
273
+ * Both artifact names are accepted because the foundation build emitter
274
+ * was renamed `dist/foundation.js → dist/entry.js` in `@uniweb/build`
275
+ * v0.14.3. A foundation built with an older @uniweb/build still produces
276
+ * the legacy name; we don't want the cascade to keep rebuilding such a
277
+ * foundation forever just because the new name is missing.
278
+ */
279
+ async function ensureFoundationFresh(foundationDir, label = 'foundation') {
280
+ const distArtifact = findFoundationDistArtifact(foundationDir)
281
+
282
+ if (!distArtifact) {
283
+ info(`Local ${label} not built yet — building ${basename(foundationDir)} first`)
284
+ log('')
285
+ await buildFoundation(foundationDir)
286
+ log('')
287
+ return { built: true, reason: 'missing' }
288
+ }
289
+
290
+ const distMtime = statSync(distArtifact).mtimeMs
291
+ const stale = isFoundationSourceNewerThan(foundationDir, distMtime)
292
+
293
+ if (stale) {
294
+ info(`Local ${label} sources changed — rebuilding ${basename(foundationDir)}`)
295
+ log('')
296
+ await buildFoundation(foundationDir)
297
+ log('')
298
+ return { built: true, reason: 'stale' }
299
+ }
300
+
301
+ return { built: false, reason: 'fresh' }
302
+ }
303
+
304
+ /**
305
+ * Locate the foundation's built entry artifact. Returns the path of the
306
+ * first match, or null when neither file exists. Prefers the current
307
+ * name (`dist/entry.js`) over the legacy one (`dist/foundation.js`).
308
+ */
309
+ function findFoundationDistArtifact(foundationDir) {
310
+ for (const name of ['entry.js', 'foundation.js']) {
311
+ const p = join(foundationDir, 'dist', name)
312
+ if (existsSync(p)) return p
313
+ }
314
+ return null
315
+ }
316
+
317
+ /**
318
+ * Returns true if any tracked foundation source file has an mtime newer
319
+ * than `referenceMtime`. Walks `<foundationDir>/src/` recursively (skipping
320
+ * dotfiles, node_modules, and dist). Also stats the root structural files.
321
+ */
322
+ function isFoundationSourceNewerThan(foundationDir, referenceMtime) {
323
+ const rootFiles = ['package.json', 'foundation.js', 'meta.js']
324
+ for (const f of rootFiles) {
325
+ const p = join(foundationDir, f)
326
+ if (existsSync(p) && statSync(p).mtimeMs > referenceMtime) return true
327
+ }
328
+
329
+ const srcDir = join(foundationDir, 'src')
330
+ if (!existsSync(srcDir)) return false
331
+
332
+ const stack = [srcDir]
333
+ while (stack.length) {
334
+ const dir = stack.pop()
335
+ let entries
336
+ try {
337
+ entries = readdirSync(dir, { withFileTypes: true })
338
+ } catch {
339
+ continue
340
+ }
341
+ for (const e of entries) {
342
+ if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue
343
+ const full = join(dir, e.name)
344
+ if (e.isDirectory()) {
345
+ stack.push(full)
346
+ continue
347
+ }
348
+ if (!e.isFile()) continue
349
+ try {
350
+ if (statSync(full).mtimeMs > referenceMtime) return true
351
+ } catch { /* ignore */ }
352
+ }
353
+ }
354
+
355
+ return false
356
+ }
357
+
246
358
  /**
247
359
  * Load site i18n configuration
248
360
  *
@@ -452,6 +564,11 @@ async function buildSiteLink(projectDir, options = {}) {
452
564
  // null and theme defaults come from theme.yml only.
453
565
  const foundationDir = await resolveFoundationDirForSite(projectDir, siteConfig).catch(() => null)
454
566
 
567
+ // Cascade: a local foundation in link mode is uploaded alongside the
568
+ // site (site-bound mode), so its dist must be current. Idempotent under
569
+ // buildWorkspace() — the freshness check no-ops when already built.
570
+ if (foundationDir) await ensureFoundationFresh(foundationDir)
571
+
455
572
  await buildSiteData({
456
573
  siteRoot: projectDir,
457
574
  distDir,
@@ -548,6 +665,12 @@ async function buildSite(projectDir, options = {}) {
548
665
 
549
666
  info('Building site...')
550
667
 
668
+ // Cascade: bundle mode imports the foundation through vite, and (when
669
+ // prerender is on) loads dist/entry.js for SSG. A local foundation must
670
+ // therefore be current. Idempotent under buildWorkspace() — when the
671
+ // workspace cascade has already built it, the freshness check no-ops.
672
+ if (foundationDir) await ensureFoundationFresh(foundationDir)
673
+
551
674
  // Run vite build for sites
552
675
  await runLocalVite(projectDir, ['build'])
553
676
 
@@ -924,7 +1047,20 @@ export async function build(args = []) {
924
1047
  if (prerenderFlag) prerender = true
925
1048
  if (noPrerenderFlag) prerender = false
926
1049
 
927
- await buildSite(projectDir, { prerender, foundationDir, siteConfig, host })
1050
+ // If `--foundation-dir` wasn't passed, resolve the local foundation
1051
+ // from site.yml + package.json. Required so buildSite() can cascade
1052
+ // to the local foundation when the user runs `uniweb build` from a
1053
+ // site dir on a fresh checkout where dist/ doesn't exist yet.
1054
+ const resolvedFoundationDir =
1055
+ foundationDir
1056
+ || (await resolveFoundationDirForSite(projectDir, siteConfig).catch(() => null))
1057
+
1058
+ await buildSite(projectDir, {
1059
+ prerender,
1060
+ foundationDir: resolvedFoundationDir,
1061
+ siteConfig,
1062
+ host,
1063
+ })
928
1064
  }
929
1065
  } catch (err) {
930
1066
  error(err.message)
@@ -36,7 +36,8 @@ import { spawn } from 'node:child_process'
36
36
  import { join } from 'node:path'
37
37
 
38
38
  import { detectPackageManager, filterCmd } from '../utils/pm.js'
39
- import { discoverSites, readWorkspaceConfig } from '../utils/config.js'
39
+ import { readWorkspaceConfig } from '../utils/config.js'
40
+ import { discoverSites } from '../utils/discover.js'
40
41
  import { findWorkspaceRoot } from '../utils/workspace.js'
41
42
  import { readFlagValue } from '../utils/args.js'
42
43
 
@@ -10,7 +10,7 @@ import { loadDeployYml } from '@uniweb/build/site'
10
10
  import { listAdapters } from '@uniweb/build/hosts'
11
11
  import { getCliVersion } from '../versions.js'
12
12
  import { readAgentsVersion } from '../utils/agents-stamp.js'
13
- import { discoverFoundations, discoverSites } from '../utils/config.js'
13
+ import { discoverFoundations, discoverSites } from '../utils/discover.js'
14
14
  import { findWorkspaceRoot } from '../utils/workspace.js'
15
15
 
16
16
  /**
@@ -47,14 +47,13 @@ import yaml from 'js-yaml'
47
47
  import { isExtensionPackage } from '@uniweb/build'
48
48
  import { findWorkspaceRoot } from '../utils/workspace.js'
49
49
  import {
50
- discoverFoundations,
51
- discoverSites,
52
50
  readWorkspaceConfig,
53
51
  writeWorkspaceConfig,
54
52
  readRootPackageJson,
55
53
  writeRootPackageJson,
56
54
  updateRootScripts,
57
55
  } from '../utils/config.js'
56
+ import { discoverFoundations, discoverSites } from '../utils/discover.js'
58
57
  import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
59
58
  import { detectPackageManager, installCmd } from '../utils/pm.js'
60
59
  import { getCliPrefix } from '../utils/interactive.js'
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-06T17:01:18.376Z",
3
+ "generatedAt": "2026-05-06T22:34:24.827Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.3",
6
+ "version": "0.14.4",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.8",
95
+ "version": "0.4.9",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
@@ -1,8 +1,17 @@
1
1
  /**
2
2
  * Workspace Config Management
3
3
  *
4
- * Read/write pnpm-workspace.yaml and root package.json.
5
- * Used by both `create` and `add` commands.
4
+ * Read/write pnpm-workspace.yaml and root package.json, plus URL config
5
+ * for the platform backend / registry. Used by both `create` and `add`
6
+ * commands.
7
+ *
8
+ * This module is on the CLI's startup path (statically imported by
9
+ * `commands/login.js`, which is loaded by `src/index.js`). It MUST NOT
10
+ * import any optional peer dependency — most importantly `@uniweb/build`,
11
+ * which is absent in `npx uniweb create` scratch dirs and global
12
+ * installs. Workspace package discovery (which does need `@uniweb/build`)
13
+ * lives in `./discover.js` and is loaded only by commands that already
14
+ * require a project context.
6
15
  */
7
16
 
8
17
  import { existsSync, readFileSync } from 'node:fs'
@@ -10,7 +19,6 @@ import { readFile, writeFile } from 'node:fs/promises'
10
19
  import { join } from 'node:path'
11
20
  import { homedir } from 'node:os'
12
21
  import yaml from 'js-yaml'
13
- import { classifyPackage } from '@uniweb/build'
14
22
  import { filterCmd } from './pm.js'
15
23
 
16
24
  // ── Platform URLs ──────────────────────────────────────────────
@@ -223,63 +231,6 @@ export async function updateRootScripts(rootDir, sites, pm = 'pnpm') {
223
231
  await writeRootPackageJson(rootDir, pkg)
224
232
  }
225
233
 
226
- /**
227
- * Discover foundations in the workspace
228
- * @param {string} rootDir - Workspace root directory
229
- * @returns {Promise<Array<{name: string, path: string}>>}
230
- */
231
- export async function discoverFoundations(rootDir) {
232
- return discoverByKind(rootDir, 'foundation')
233
- }
234
-
235
- /**
236
- * Discover sites in the workspace
237
- * @param {string} rootDir - Workspace root directory
238
- * @returns {Promise<Array<{name: string, path: string}>>}
239
- */
240
- export async function discoverSites(rootDir) {
241
- return discoverByKind(rootDir, 'site')
242
- }
243
-
244
- /**
245
- * Walk the workspace globs and return packages of the requested kind.
246
- * Uses `classifyPackage` from @uniweb/build — the canonical classifier
247
- * shared with the build pipeline, which keys on real signals (site.yml
248
- * for sites, generated entry for foundations) rather than which
249
- * `@uniweb/*` packages happen to be in dependencies. Templates whose
250
- * sites pull runtime transitively through the foundation (e.g.,
251
- * marketing) used to be invisible to the older dependency-based check.
252
- */
253
- async function discoverByKind(rootDir, kind) {
254
- const { packages } = await readWorkspaceConfig(rootDir)
255
- const out = []
256
-
257
- for (const pattern of packages) {
258
- const dirs = await resolveGlob(rootDir, pattern)
259
- for (const dir of dirs) {
260
- const fullPath = join(rootDir, dir)
261
- if (classifyPackage(fullPath) !== kind) continue
262
-
263
- // Read package.json for the package name. Synthesize one from
264
- // the directory if it's missing or malformed — we still want
265
- // the package to surface in pickers.
266
- const pkgPath = join(fullPath, 'package.json')
267
- let name = dir.split('/').pop()
268
- if (existsSync(pkgPath)) {
269
- try {
270
- const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
271
- if (pkg.name) name = pkg.name
272
- } catch {
273
- // keep directory-derived name
274
- }
275
- }
276
- out.push({ name, path: dir })
277
- }
278
- }
279
-
280
- return out
281
- }
282
-
283
234
  // Resolve a workspace glob pattern to actual directories
284
235
  export async function resolveGlob(rootDir, pattern) {
285
236
  const clean = pattern.replace(/^["']|["']$/g, '')
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Workspace package discovery
3
+ *
4
+ * Walks the workspace globs and classifies each package as a site or
5
+ * foundation using `classifyPackage` from `@uniweb/build` — the canonical
6
+ * classifier shared with the build pipeline. It keys on real signals
7
+ * (site.yml for sites, generated entry for foundations) rather than which
8
+ * `@uniweb/*` packages happen to be in dependencies, so templates whose
9
+ * sites pull runtime transitively through the foundation (e.g., marketing)
10
+ * are classified correctly.
11
+ *
12
+ * Why this lives in its own file: `@uniweb/build` is an OPTIONAL peer
13
+ * dependency of the CLI. The CLI's startup path (`src/index.js` and
14
+ * everything it statically imports) MUST run in environments where
15
+ * `@uniweb/build` is not installed — `npx uniweb create` in a scratch
16
+ * dir, `npm i -g uniweb` before any project exists, etc. Anything that
17
+ * imports `@uniweb/build` therefore must NOT be reachable from the
18
+ * startup graph; it must be loaded dynamically by commands that already
19
+ * require a project context. Keeping discovery in this dedicated module
20
+ * makes that boundary structural rather than conventional.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs'
24
+ import { readFile } from 'node:fs/promises'
25
+ import { join } from 'node:path'
26
+ import { classifyPackage } from '@uniweb/build'
27
+ import { readWorkspaceConfig, resolveGlob } from './config.js'
28
+
29
+ /**
30
+ * Discover foundations in the workspace.
31
+ * @param {string} rootDir - Workspace root directory
32
+ * @returns {Promise<Array<{name: string, path: string}>>}
33
+ */
34
+ export async function discoverFoundations(rootDir) {
35
+ return discoverByKind(rootDir, 'foundation')
36
+ }
37
+
38
+ /**
39
+ * Discover sites in the workspace.
40
+ * @param {string} rootDir - Workspace root directory
41
+ * @returns {Promise<Array<{name: string, path: string}>>}
42
+ */
43
+ export async function discoverSites(rootDir) {
44
+ return discoverByKind(rootDir, 'site')
45
+ }
46
+
47
+ async function discoverByKind(rootDir, kind) {
48
+ const { packages } = await readWorkspaceConfig(rootDir)
49
+ const out = []
50
+
51
+ for (const pattern of packages) {
52
+ const dirs = await resolveGlob(rootDir, pattern)
53
+ for (const dir of dirs) {
54
+ const fullPath = join(rootDir, dir)
55
+ if (classifyPackage(fullPath) !== kind) continue
56
+
57
+ // Read package.json for the package name. Synthesize one from
58
+ // the directory if it's missing or malformed — we still want
59
+ // the package to surface in pickers.
60
+ const pkgPath = join(fullPath, 'package.json')
61
+ let name = dir.split('/').pop()
62
+ if (existsSync(pkgPath)) {
63
+ try {
64
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
65
+ if (pkg.name) name = pkg.name
66
+ } catch {
67
+ // keep directory-derived name
68
+ }
69
+ }
70
+ out.push({ name, path: dir })
71
+ }
72
+ }
73
+
74
+ return out
75
+ }
@@ -5,7 +5,8 @@
5
5
  * and detects collisions with existing workspace packages.
6
6
  */
7
7
 
8
- import { discoverFoundations, discoverSites, readWorkspaceConfig, resolveGlob } from './config.js'
8
+ import { readWorkspaceConfig, resolveGlob } from './config.js'
9
+ import { discoverFoundations, discoverSites } from './discover.js'
9
10
  import { existsSync } from 'node:fs'
10
11
  import { readFile } from 'node:fs/promises'
11
12
  import { join } from 'node:path'