uniweb 0.57.0 → 0.58.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",
3
- "version": "0.57.0",
3
+ "version": "0.58.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,17 +41,17 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/core": "^0.29.5",
45
+ "@uniweb/runtime": "^0.26.6",
44
46
  "@uniweb/semantic-parser": "^1.4.1",
45
- "@uniweb/schemas": "^0.3.3",
46
- "@uniweb/core": "^0.29.4",
47
47
  "@uniweb/content-writer": "^0.3.4",
48
- "@uniweb/runtime": "^0.26.5",
49
- "@uniweb/kit": "^0.19.3"
48
+ "@uniweb/kit": "^0.19.3",
49
+ "@uniweb/schemas": "^0.3.3"
50
50
  },
51
51
  "peerDependencies": {
52
+ "@uniweb/build": "^0.54.1",
52
53
  "@uniweb/content-reader": "^1.2.5",
53
- "@uniweb/semantic-parser": "^1.4.1",
54
- "@uniweb/build": "^0.53.0"
54
+ "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -694,7 +694,7 @@ end the list with `...`.**
694
694
 
695
695
  **Configuration cascades: `page.yml` → `folder.yml` → `site.yml` → foundation defaults.** Each level inherits from the one above and overrides specific values, the way CSS specificity works. This is what makes bulk assignment natural — put `layout: marketing` in a `folder.yml` and every page in that folder inherits it, while one page can still override with its own `page.yml`. Reach for `folder.yml` before editing the same key into a dozen `page.yml` files.
696
696
 
697
- **Route mapping:** folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root, where `index:` (or first in `pages:`) sets `/`.
697
+ **Route mapping:** folder structure maps 1:1 to routes. Every folder keeps its natural route — `pages:` controls **order only**, not which child "becomes" the parent. The only exception is the site root, where `index:` (or first in `pages:`) sets `/` — declared in `site.yml`, or in the pages directory's own `folder.yml` / `page.yml` (how a content repo carries its order), with `site.yml` winning.
698
698
 
699
699
  **Content-less containers:** folders with `page.yml` but no markdown are structural groups (`hasContent: false`). Visiting one auto-redirects to the first descendant with content — this is what supports courses → modules → lessons at any depth.
700
700
 
@@ -355,6 +355,13 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
355
355
  // both. Format validation runs on the derived package name below, not
356
356
  // on the raw input — slashes in the input are intentional path syntax.
357
357
  const placement = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
358
+ if (placement.outsideRoot) {
359
+ error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
360
+ log('')
361
+ log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
362
+ log(`A package outside it cannot be a workspace member.`)
363
+ process.exit(1)
364
+ }
358
365
  const { relativePath } = placement
359
366
  let { packageName } = placement
360
367
  const fullPath = join(rootDir, relativePath)
@@ -470,6 +477,13 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
470
477
 
471
478
  // Resolve placement first (path + package name); see notes in addFoundation.
472
479
  const placement = resolvePlacement(rootDir, name, opts, SITE_KIND)
480
+ if (placement.outsideRoot) {
481
+ error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
482
+ log('')
483
+ log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
484
+ log(`A package outside it cannot be a workspace member.`)
485
+ process.exit(1)
486
+ }
473
487
  const { relativePath } = placement
474
488
  let siteName = placement.packageName
475
489
  const fullPath = join(rootDir, relativePath)
@@ -271,6 +271,14 @@ export async function clone(args = [], deps = {}) {
271
271
  { path: pathFlag, project: projectFlag },
272
272
  SITE_KIND
273
273
  )
274
+ if (placement.outsideRoot) {
275
+ error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
276
+ note(
277
+ `The workspace root is ${existingRoot}. A package outside it cannot be a ` +
278
+ `workspace member — the directory and the manifests would disagree.`
279
+ )
280
+ return { exitCode: 1 }
281
+ }
274
282
  siteDir = join(existingRoot, placement.relativePath)
275
283
  sitePkgName = placement.packageName
276
284
  workspaceName = sitePkgName
@@ -156,14 +156,39 @@ function shouldFetchAssets(args, siteDir) {
156
156
  return true
157
157
  }
158
158
 
159
- // Read a top-level `$uuid:` scalar from a YAML file, or null.
159
+ // Read a top-level `$uuid:` scalar from a YAML file.
160
+ //
161
+ // ⛔ THREE STATES, AND COLLAPSING THEM TO `null` IS A LIE TO THE USER.
162
+ // This returned `null` on any throw, so a site.yml that EXISTS and carries a
163
+ // `$uuid:` line but does not PARSE was reported as "this project has no $uuid
164
+ // yet. Run `uniweb push` first." — advice that is wrong, and actively harmful
165
+ // on a cloned site, since `push` would send an empty tree at the uuid the file
166
+ // already holds.
167
+ //
168
+ // It is also invisible: the user greps the file, sees `$uuid:`, and disbelieves
169
+ // the CLI. The clone suite's own comment records an earlier round of exactly
170
+ // this ("an unquoted `@…` scalar matched a regex here while the file did not
171
+ // parse, and `pull` then found no `$uuid`") — that CAUSE was fixed by quoting
172
+ // the ref; the SYMPTOM stayed reachable by any other parse error.
173
+ // Re-surfaced 2026-09-18 by the backend lane, whose clone wrote a `$uuid:` and
174
+ // whose pull then said there was none.
175
+ //
176
+ // ⇒ Name the state. A missing file and a `$uuid`-less file are "no uuid"; a
177
+ // file that will not parse is an error with the parser's own message.
160
178
  function readYamlUuid(filePath) {
179
+ let text
161
180
  try {
162
- const obj = yaml.load(readFileSync(filePath, 'utf8'))
163
- return typeof obj?.$uuid === 'string' ? obj.$uuid : null
181
+ text = readFileSync(filePath, 'utf8')
164
182
  } catch {
165
- return null
183
+ return { uuid: null } // no site.yml — genuinely nothing to pull
184
+ }
185
+ let obj
186
+ try {
187
+ obj = yaml.load(text)
188
+ } catch (err) {
189
+ return { uuid: null, unparsed: err.message.split('\n')[0] }
166
190
  }
191
+ return { uuid: typeof obj?.$uuid === 'string' ? obj.$uuid : null }
167
192
  }
168
193
 
169
194
  // Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
@@ -583,7 +608,20 @@ export async function pull(args = [], deps = {}) {
583
608
 
584
609
  // One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
585
610
  // by it — the backend resolves the site's `@uniweb/folder` from this uuid.
586
- const siteContentUuid = readYamlUuid(join(siteDir, 'site.yml'))
611
+ const siteYmlPath = join(siteDir, 'site.yml')
612
+ const identity = readYamlUuid(siteYmlPath)
613
+
614
+ // A file that will not parse is an error, not an absence — see readYamlUuid.
615
+ if (identity.unparsed) {
616
+ error(`site.yml did not parse: ${identity.unparsed}`)
617
+ note(siteYmlPath)
618
+ note(
619
+ 'Nothing was read, so the $uuid in it (if any) was not seen. Fix the YAML and re-run.'
620
+ )
621
+ return { exitCode: 1 }
622
+ }
623
+
624
+ const siteContentUuid = identity.uuid
587
625
 
588
626
  if (!siteContentUuid) {
589
627
  info(
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-18T17:09:07.963Z",
3
+ "generatedAt": "2026-09-20T02:07:02.158Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.4.0",
6
+ "version": "0.4.1",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.53.0",
13
+ "version": "0.54.1",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.29.4",
38
+ "version": "0.29.5",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.26.5",
86
+ "version": "0.26.6",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.9.23",
129
+ "version": "0.9.25",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",
@@ -1,3 +1,5 @@
1
+ import { isAbsolute, relative, sep } from 'node:path'
2
+
1
3
  /**
2
4
  * Package placement resolution — shared by `add` and `clone`.
3
5
  *
@@ -7,6 +9,31 @@
7
9
  * (the same reason `utils/workspace.js` loads the classifier lazily).
8
10
  */
9
11
 
12
+ // `--path` is documented as a folder INSIDE the workspace, and every consumer of
13
+ // the result treats it that way: the caller does `join(rootDir, relativePath)`
14
+ // while the workspace manifests get `relativePath` verbatim. An ABSOLUTE path
15
+ // silently satisfies neither — `join('/ws', '/tmp/x')` is `/ws/tmp/x`, so the
16
+ // directory is created in one place while `package.json::workspaces` and
17
+ // `pnpm-workspace.yaml` record `/tmp/x`. The two disagree, and the manifests are
18
+ // committed files carrying a path that exists on exactly one machine.
19
+ //
20
+ // Reported by the backend lane 2026-09-18 from a `clone --path <abs>`: the site
21
+ // landed under `company-websites/tmp/claude-502/clonetest/` and both manifests
22
+ // gained the absolute string.
23
+ //
24
+ // So: accept an absolute path when it names a folder inside the root (people type
25
+ // one, and it is unambiguous), by rewriting it to the root-relative form every
26
+ // consumer already expects. Refuse it when it escapes — a workspace package cannot
27
+ // live outside its workspace, and guessing which half the user meant is how you get
28
+ // the split above.
29
+ function normalizeParent(given, rootDir) {
30
+ const trimmed = String(given).replace(/\/+$/, '')
31
+ if (!isAbsolute(trimmed)) return { relative: trimmed }
32
+ const rel = relative(rootDir || '', trimmed)
33
+ if (!rel || rel === '..' || rel.startsWith('../')) return { outsideRoot: true, given: trimmed }
34
+ return { relative: rel.split(sep).join('/') }
35
+ }
36
+
10
37
  /** Foundation placement defaults (folder `src/`, package `src`). */
11
38
  export const FOUNDATION_KIND = {
12
39
  defaultDir: 'src',
@@ -59,17 +86,19 @@ export function resolvePlacement(rootDir, name, opts, kind) {
59
86
  // name was given, or `<path>` itself if not (the path's last segment
60
87
  // is then taken as the package name).
61
88
  if (opts.path) {
62
- const parent = opts.path.replace(/\/+$/, '')
89
+ const parent = normalizeParent(opts.path, rootDir)
90
+ if (parent.outsideRoot) return { outsideRoot: parent.given, relativePath: null, packageName: null }
91
+ const dir = parent.relative
63
92
  if (name) {
64
93
  const last = name.split('/').filter(Boolean).pop()
65
94
  return {
66
- relativePath: `${parent}/${name}`.replace(/\/+/g, '/'),
95
+ relativePath: `${dir}/${name}`.replace(/\/+/g, '/'),
67
96
  packageName: last
68
97
  }
69
98
  }
70
- const lastSegment = parent.split('/').filter(Boolean).pop() || parent
99
+ const lastSegment = dir.split('/').filter(Boolean).pop() || dir
71
100
  return {
72
- relativePath: parent,
101
+ relativePath: dir,
73
102
  packageName: lastSegment
74
103
  }
75
104
  }
@@ -28,7 +28,7 @@
28
28
  * and it has to move the readers and the writers together or it makes the split worse.
29
29
  */
30
30
 
31
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
31
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
32
32
  import { join, dirname } from 'node:path'
33
33
  import yaml from 'js-yaml'
34
34
  import { DEFAULT_BACKEND_ORIGIN } from './config.js'
@@ -136,10 +136,25 @@ export async function recordSiteBackend(siteDir, origin, deps = {}) {
136
136
  // Lazy by necessity, not by taste — see the header.
137
137
  mod = await loadUwx()
138
138
  } catch {
139
- // No `@uniweb/build` at all. It is an OPTIONAL peer, so this is a supported
140
- // configuration and not worth a word — the scope simply goes unrecorded, which is
141
- // the behaviour everyone had before this key existed.
142
- return null
139
+ // ⛔ NO `@uniweb/build` — and this is the COMMON case on the path that needs it
140
+ // most, not the rare one this branch used to assume.
141
+ //
142
+ // `clone` calls us straight after scaffolding and BEFORE `pnpm install`, so the
143
+ // site has no `node_modules` yet; under `npx uniweb@latest` the CLI's own tree
144
+ // may not carry build either (it is an optional peer). The import therefore
145
+ // fails on a FRESH CLONE essentially always, and returning null wrote nothing.
146
+ //
147
+ // The cost is not cosmetic: the clone then holds a `$uuid` minted by the
148
+ // `--backend` origin it was given, with nothing recording that origin, so the
149
+ // scope guard infers the DEFAULT backend and stops the very next command with
150
+ // "this project's stored identity belongs to https://uniweb.app". The origin was
151
+ // on the command line the whole time.
152
+ // Measured by the backend lane 2026-09-18: `0.48.5` recorded `$backend`,
153
+ // `0.57.0` did not, and their clone was unusable until they added the line by hand.
154
+ //
155
+ // ⇒ Fall back to a dependency-free write. `js-yaml` is the CLI's own direct
156
+ // dependency, so this path needs nothing installed in the project.
157
+ return writeSiteBackendFallback(siteDir, norm)
143
158
  }
144
159
 
145
160
  // ⚠️ PRESENT BUT TOO OLD is a different problem, and it must not look like the one
@@ -167,6 +182,41 @@ export async function recordSiteBackend(siteDir, origin, deps = {}) {
167
182
  }
168
183
  }
169
184
 
185
+ /**
186
+ * Write `$backend` into `site.yml` without `@uniweb/build` — the fallback for a
187
+ * project whose dependencies are not installed yet (see `recordSiteBackend`).
188
+ *
189
+ * ⭐ Verifies the RESULT rather than trusting the edit: the file must still parse and
190
+ * must read back the origin we meant to write. A write that corrupts `site.yml` would
191
+ * be far worse than an unrecorded scope — an unparseable `site.yml` is the failure this
192
+ * same channel spent an evening on.
193
+ *
194
+ * @returns {string|null} the recorded origin, or null when nothing was written
195
+ */
196
+ function writeSiteBackendFallback(siteDir, norm) {
197
+ const file = join(siteDir, 'site.yml')
198
+ let text
199
+ try {
200
+ text = readFileSync(file, 'utf8')
201
+ } catch {
202
+ return null // no site.yml — nothing to annotate, and we do not create one
203
+ }
204
+ try {
205
+ // Quote through js-yaml rather than by hand: an origin is plain today, but the
206
+ // value is not ours to assume. Replace an existing key, else prepend.
207
+ const scalar = yaml.dump(norm, { lineWidth: -1 }).trim()
208
+ const next = /^\$backend:/m.test(text)
209
+ ? text.replace(/^\$backend:.*$/m, `$backend: ${scalar}`)
210
+ : `$backend: ${scalar}\n` + text
211
+ const parsed = yaml.load(next)
212
+ if (!parsed || typeof parsed !== 'object' || parsed.$backend !== norm) return null
213
+ writeFileSync(file, next)
214
+ return norm
215
+ } catch {
216
+ return null
217
+ }
218
+ }
219
+
170
220
  /**
171
221
  * Refuse to act on a site whose stored identity belongs to a different backend.
172
222
  *
@@ -31,6 +31,12 @@ index: home
31
31
  # (pages/docs/) holding a folder.yml — folder.yml, not page.yml, because the
32
32
  # mounted directory is a folder of pages. Whatever that stub leaves unset comes
33
33
  # from the mounted directory's own folder.yml, so it need only say what differs.
34
+ #
35
+ # ! A `pages/<segment>:` sub-mount is BUILD-ONLY today. `uniweb export` and
36
+ # `uniweb deploy --host <adapter>` mount it normally, but `uniweb push` /
37
+ # `uniweb publish` do not read the key, so the mounted pages do not reach a
38
+ # backend (the CLI warns when it skips them). The whole-directory form
39
+ # `paths: { pages: <dir> }` IS carried by both. Static hosting: use either.
34
40
 
35
41
  # ─── Structured Content ───────────────────────────────────────────────────────
36
42
  # Three things, and they are separate on purpose: