uniweb 1.1.0 → 1.2.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": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/semantic-parser": "~1.5.1",
45
44
  "@uniweb/core": "~1.1.0",
46
45
  "@uniweb/content-writer": "~1.0.1",
46
+ "@uniweb/semantic-parser": "~1.5.1",
47
47
  "@uniweb/schemas": "~1.0.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/content-reader": "~1.2.8",
50
+ "@uniweb/build": "~1.1.1",
51
51
  "@uniweb/semantic-parser": "~1.5.1",
52
- "@uniweb/build": "~1.1.0"
52
+ "@uniweb/content-reader": "~1.2.8"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -194,6 +194,7 @@ The rest of this guide explains how Uniweb works. This part is what to do *first
194
194
  | `src`, `site` | **single** (the default) | foundation in `src/`, site in `site/` |
195
195
  | `foundations/*`, `sites/*` | **segregated** | several sites may share one foundation — a foundation edit hits all of them |
196
196
  | `*/src`, `*/site` | **co-located projects** | one self-contained pair per project: `docs/src` + `docs/site` |
197
+ | `*/src`, `*/sites/*` | **projects with several sites** | each project's foundation with its sites: `memberships/src` + `memberships/sites/fitness`, `memberships/sites/society` — a foundation edit hits every site in its project |
197
198
  | a package whose `main.js` says `extension: true` — `effects`, `extensions/*` (alongside any of the above) | extensions present | extra section types, runtime-loaded — see step 2 |
198
199
 
199
200
  Those names are convention; the globs are the truth. Full guide with the wiring for each: `development/project-structures.md`.
@@ -843,7 +843,9 @@ export class BackendClient {
843
843
  * shipped backend-side — collab backend↔framework):
844
844
  * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
845
845
  * `draft_dirty` = never-published, or the synced draft changed since the last
846
- * publish ("pushed but not published").
846
+ * publish ("pushed but not published"). Since 2026-10-13 it also carries `editors`:
847
+ * who has the site open in the app right now, and on what — `status --remote` shows
848
+ * it (`describeEditors`, commands/status.js).
847
849
  *
848
850
  * ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
849
851
  * it yet** (shipped 2026-08-29; documented here so it is not lost twice):
@@ -59,7 +59,8 @@ import {
59
59
  detectFoundationType,
60
60
  isExtensionUrl,
61
61
  readFoundationName,
62
- checkFoundationName
62
+ checkFoundationName,
63
+ findSiteExtension
63
64
  } from '@uniweb/build'
64
65
  import { computeFoundationDigest } from '../utils/code-upload.js'
65
66
  import { isNonInteractive } from '../utils/interactive.js'
@@ -127,15 +128,24 @@ export function resolveLocalExtensions(siteDir, siteYml) {
127
128
  ? entry
128
129
  : null
129
130
  if (!decl || isExtensionUrl(decl)) continue
130
- let info
131
+ // Resolved as the primary foundation is — and, where that finds nothing, where
132
+ // extensions live: the rule the build follows for a site-relative extension
133
+ // (`findSiteExtension`), which looks in `extensions/<name>/` and in the folders
134
+ // above the site. It takes a plain name only, so a catalog ref stays not local.
135
+ // ⛔ Until 2026-10-13 only the first: a site naming the `extensions` template's
136
+ // own `extensions/effects` — as publish's refusal of `/effects/entry.js`
137
+ // advises — released nothing and sent an unpinned ref the host cannot serve.
138
+ let dir = null
131
139
  try {
132
- info = detectFoundationType(decl, siteDir)
140
+ const info = detectFoundationType(decl, siteDir)
141
+ if (info?.type === 'local' && info.path) dir = info.path
133
142
  } catch {
134
- // Unresolved — the site build surfaces the canonical error; nothing local.
135
- continue
143
+ // Not a foundation's shape — tried as an extension below.
136
144
  }
137
- if (!info || info.type !== 'local' || !info.path) continue
138
- out.push({ decl, dir: info.path, version: readPkgField(info.path, 'version') })
145
+ dir ??= findSiteExtension(decl, siteDir)
146
+ // Still nothing: the site build surfaces the canonical error; nothing local.
147
+ if (!dir) continue
148
+ out.push({ decl, dir, version: readPkgField(dir, 'version') })
139
149
  }
140
150
  return out
141
151
  }
@@ -11,12 +11,16 @@
11
11
  * uniweb add section <name> [--foundation <name>]
12
12
  */
13
13
 
14
- import { existsSync } from 'node:fs'
14
+ import { existsSync, realpathSync } from 'node:fs'
15
15
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
16
  import { join, relative, basename } from 'node:path'
17
17
  import prompts from 'prompts'
18
18
  import yaml from 'js-yaml'
19
- import { resolveFoundationSrcPath, readFoundationName } from '@uniweb/build'
19
+ import {
20
+ resolveFoundationSrcPath,
21
+ readFoundationName,
22
+ findSiteExtension
23
+ } from '@uniweb/build'
20
24
  import {
21
25
  scaffoldFoundation,
22
26
  scaffoldSite,
@@ -26,15 +30,12 @@ import {
26
30
  } from '../utils/scaffold.js'
27
31
  import {
28
32
  resolvePlacement,
33
+ projectConflictLines,
29
34
  SITE_KIND,
30
35
  FOUNDATION_KIND,
31
36
  EXTENSION_KIND
32
37
  } from '../utils/placement.js'
33
- import {
34
- readWorkspaceConfig,
35
- addWorkspaceGlob,
36
- updateRootScripts
37
- } from '../utils/config.js'
38
+ import { registerWorkspacePackage, updateRootScripts } from '../utils/config.js'
38
39
  import { discoverFoundations, discoverSites } from '../utils/discover.js'
39
40
  import { generateStarter, reportStarter, declarationFor } from './starter.js'
40
41
  import {
@@ -360,6 +361,7 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
360
361
  // both. Format validation runs on the derived package name below, not
361
362
  // on the raw input — slashes in the input are intentional path syntax.
362
363
  const placement = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
364
+ refuseProjectConflict(placement, 'foundation', name, opts)
363
365
  if (placement.outsideRoot) {
364
366
  error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
365
367
  log('')
@@ -470,10 +472,10 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
470
472
  ensureFoundationName(fullPath, registryName)
471
473
  }
472
474
 
473
- // Register the package in pnpm-workspace.yaml — by exact path, not by glob.
474
- // No glob inference: if the user wants `foundations/*` they can edit the
475
- // workspace file themselves. This matches the "no assumptions" rule.
476
- await addWorkspaceGlob(rootDir, relativePath)
475
+ // Register the package by its exact path — unless a pattern the workspace lists
476
+ // already covers it (`foundations/*`, `*/src`), which leaves the file as it is.
477
+ // No glob inference: a pattern is the user's to write.
478
+ await registerWorkspacePackage(rootDir, relativePath)
477
479
 
478
480
  // Update root scripts
479
481
  const sites = await discoverSites(rootDir)
@@ -486,6 +488,18 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
486
488
  log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset}`)
487
489
  }
488
490
 
491
+ /**
492
+ * Stop when `--project` came with a name or a `--path` — it names the folder itself,
493
+ * so the two disagree. Refused, never ignored (`projectConflictLines`).
494
+ */
495
+ function refuseProjectConflict(placement, kind, name, opts) {
496
+ if (!placement.projectConflict) return
497
+ const [first, ...rest] = projectConflictLines(kind, name, opts, `${getCliPrefix()} add ${kind}`)
498
+ error(first)
499
+ for (const line of rest) log(line)
500
+ process.exit(1)
501
+ }
502
+
489
503
  /**
490
504
  * Add a site to the workspace
491
505
  */
@@ -495,6 +509,7 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
495
509
 
496
510
  // Resolve placement first (path + package name); see notes in addFoundation.
497
511
  const placement = resolvePlacement(rootDir, name, opts, SITE_KIND)
512
+ refuseProjectConflict(placement, 'site', name, opts)
498
513
  if (placement.outsideRoot) {
499
514
  error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
500
515
  log('')
@@ -608,8 +623,8 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
608
623
  await applyFromTemplate(opts.from, 'site', fullPath, projectName)
609
624
  }
610
625
 
611
- // Register the package by exact path. No glob inference.
612
- await addWorkspaceGlob(rootDir, relativePath)
626
+ // Register the package by exact path, unless a listed pattern covers it already.
627
+ await registerWorkspacePackage(rootDir, relativePath)
613
628
 
614
629
  // Update root scripts (discover sites after registration — includes the new one)
615
630
  const sites = await discoverSites(rootDir)
@@ -678,6 +693,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
678
693
  // you name is the folder created — `effects` → effects/, `extensions/effects` →
679
694
  // extensions/effects/ — and `--path` is the folder it goes in.
680
695
  const placement = resolvePlacement(rootDir, name, opts, EXTENSION_KIND)
696
+ refuseProjectConflict(placement, 'extension', name, opts)
681
697
  if (placement.outsideRoot) {
682
698
  error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
683
699
  log('')
@@ -739,7 +755,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
739
755
  }
740
756
 
741
757
  // Register the folder it is in, as add foundation does
742
- await addWorkspaceGlob(rootDir, relativePath)
758
+ await registerWorkspacePackage(rootDir, relativePath)
743
759
 
744
760
  // Wire extension to site:
745
761
  // - --site <name>: explicit, wire it.
@@ -750,60 +766,57 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
750
766
  // warning so the user/agent knows wiring is pending, and exit 0
751
767
  // (the extension itself is fine).
752
768
  // - no sites: print a note and exit 0.
769
+ // ⭐ And only when that site can load it (extensionUnreachable): an entry the
770
+ // build cannot serve would build with a warning and 404 at runtime.
753
771
  let wiredSite = null
754
772
  let unwiredReason = null
755
- // A site loads a workspace extension from `/<name>/entry.js`, which the build
756
- // finds in `<name>/` or `extensions/<name>/` (wireExtensionToSite). An entry
757
- // pointing anywhere else would build with a warning and 404 at runtime, so
758
- // none is written.
759
- const reachable = relativePath === name || relativePath === `extensions/${name}`
760
- if (!reachable) {
761
- unwiredReason =
762
- `A site loads a workspace extension from /${name}/entry.js, which the build finds in ` +
763
- `${name}/ or extensions/${name}/ — not ${relativePath}/. Move the folder to one of those, ` +
764
- `or list a URL where the built extension is served under <site>/site.yml::extensions:.`
765
- } else if (opts.site) {
766
- wiredSite = await wireExtensionToSite(rootDir, opts.site, relativePath)
767
- } else {
768
- const sites = await discoverSites(rootDir)
769
- if (sites.length === 1) {
770
- wiredSite = await wireExtensionToSite(rootDir, sites[0].name, relativePath)
771
- } else if (sites.length > 1) {
772
- if (isNonInteractive(process.argv)) {
773
- unwiredReason = `Multiple sites in workspace; extension not wired. Re-run with --site <name>, or edit <site>/site.yml::extensions: manually.`
774
- } else {
775
- const sortedSites = [...sites].sort((a, b) =>
776
- a.name.localeCompare(b.name)
777
- )
778
- const response = await prompts(
779
- {
780
- type: 'select',
781
- name: 'site',
782
- message: 'Which site is this extension for?',
783
- choices: sortedSites.map((s) => ({
784
- title: s.name,
785
- description: s.path,
786
- value: s.name
787
- }))
788
- },
789
- {
790
- onCancel: () => {
791
- log('\nCancelled.')
792
- process.exit(0)
793
- }
773
+ let target = null
774
+ const sites = await discoverSites(rootDir)
775
+ if (opts.site) {
776
+ target = sites.find((s) => s.name === opts.site) || null
777
+ if (!target) {
778
+ unwiredReason = `Could not find site '${opts.site}' to wire this extension to. Available: ${sites.map((s) => s.name).join(', ') || 'none'}.`
779
+ }
780
+ } else if (sites.length === 1) {
781
+ target = sites[0]
782
+ } else if (sites.length > 1) {
783
+ if (isNonInteractive(process.argv)) {
784
+ unwiredReason = `Multiple sites in workspace; extension not wired. Re-run with --site <name>, or edit <site>/site.yml::extensions: manually.`
785
+ } else {
786
+ const sortedSites = [...sites].sort((a, b) =>
787
+ a.name.localeCompare(b.name)
788
+ )
789
+ const response = await prompts(
790
+ {
791
+ type: 'select',
792
+ name: 'site',
793
+ message: 'Which site is this extension for?',
794
+ choices: sortedSites.map((s) => ({
795
+ title: s.name,
796
+ description: s.path,
797
+ value: s.name
798
+ }))
799
+ },
800
+ {
801
+ onCancel: () => {
802
+ log('\nCancelled.')
803
+ process.exit(0)
794
804
  }
795
- )
796
- if (response.site) {
797
- wiredSite = await wireExtensionToSite(rootDir, response.site, relativePath)
798
805
  }
799
- }
800
- } else {
801
- unwiredReason = `No site in this workspace yet. Wire this extension into a site's site.yml::extensions: once you create one.`
806
+ )
807
+ target = sites.find((s) => s.name === response.site) || null
808
+ }
809
+ } else {
810
+ unwiredReason = `No site in this workspace yet. Wire this extension into a site's site.yml::extensions: once you create one.`
811
+ }
812
+ if (target) {
813
+ unwiredReason = extensionUnreachable(rootDir, target, name, fullPath)
814
+ if (!unwiredReason) {
815
+ wiredSite = await wireExtensionToSite(rootDir, target.name, relativePath)
802
816
  }
803
817
  }
804
818
 
805
- // Update root scripts
806
- const sites = await discoverSites(rootDir)
819
+ // Update root scripts (an extension adds no site, so the list read above stands)
807
820
  await updateRootScripts(rootDir, sites, pm)
808
821
 
809
822
  let msg = `Created extension '${name}' at ${relativePath}/`
@@ -932,9 +945,10 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
932
945
  )
933
946
  }
934
947
 
935
- // Update workspace globs for co-located layout
936
- await addWorkspaceGlob(rootDir, '*/src')
937
- await addWorkspaceGlob(rootDir, '*/site')
948
+ // The co-located patterns — each added only if what it lists is not covered yet
949
+ // (a workspace listing `*/src` and `*/sites/*` keeps its file as it is for `src/`).
950
+ await registerWorkspacePackage(rootDir, `${name}/src`, { entry: '*/src' })
951
+ await registerWorkspacePackage(rootDir, `${name}/site`, { entry: '*/site' })
938
952
 
939
953
  // Update root scripts
940
954
  const sites = await discoverSites(rootDir)
@@ -1124,6 +1138,41 @@ async function applyFromTemplate(
1124
1138
  }
1125
1139
  }
1126
1140
 
1141
+ /**
1142
+ * Why `site` cannot load the extension just made at `extensionDir` — or null when
1143
+ * it can. A site loads `/<name>/entry.js` from the folder `@uniweb/build` finds for
1144
+ * `<name>` (`findSiteExtension`): `<name>/` or `extensions/<name>/`, in the folder
1145
+ * the site is in or one above it, up to the workspace root. The build, prerender
1146
+ * and the dev server follow that rule, so `add` asks it rather than restating it.
1147
+ *
1148
+ * ⛔ Until 2026-10-13 this was a copy of the rule, judged from the workspace root
1149
+ * while the build looked only in the folder the site is in — so a site at
1150
+ * `sites/main` was wired to `extensions/effects`, and its build could not find it.
1151
+ *
1152
+ * @param {string} rootDir
1153
+ * @param {{ name: string, path: string }} site - `path` relative to `rootDir`
1154
+ * @param {string} name - the extension's folder name: its URL's first segment
1155
+ * @param {string} extensionDir - absolute
1156
+ * @returns {string|null}
1157
+ */
1158
+ function extensionUnreachable(rootDir, site, name, extensionDir) {
1159
+ const found = findSiteExtension(name, join(rootDir, site.path))
1160
+ if (found && realpathSync(found) === realpathSync(extensionDir)) return null
1161
+ const here = relative(rootDir, extensionDir)
1162
+ if (found) {
1163
+ return (
1164
+ `Site '${site.name}' would load the nearer extension named '${name}', at ` +
1165
+ `${relative(rootDir, found)}/ — not ${here}/. Rename one of them.`
1166
+ )
1167
+ }
1168
+ return (
1169
+ `A site loads a workspace extension from /${name}/entry.js, which the build finds in ` +
1170
+ `${name}/ or extensions/${name}/ in the folder the site is in or a folder above it, up to ` +
1171
+ `the workspace root. For ${site.path}/ that is not ${here}/: move the folder there, or list a ` +
1172
+ `URL where the built extension is served under ${site.path}/site.yml::extensions:.`
1173
+ )
1174
+ }
1175
+
1127
1176
  /**
1128
1177
  * Wire an extension URL to a site's site.yml
1129
1178
  */
@@ -1147,10 +1196,11 @@ async function wireExtensionToSite(rootDir, siteName, extensionFolder) {
1147
1196
  const config = yaml.load(content) || {}
1148
1197
 
1149
1198
  // ⭐ The site loads it from its own origin at `/<name>/entry.js`, `<name>`
1150
- // being the extension's folder: the build finds that extension's build in
1151
- // `<name>/` or `extensions/<name>/`, copies it into the site's output there,
1152
- // and prerender imports it from the same place (`@uniweb/build`,
1153
- // resolveExtensionDist) — the form the `extensions` template declares.
1199
+ // being the extension's folder: the build finds that extension by name
1200
+ // (`@uniweb/build`, findSiteExtension — the caller has checked it finds THIS
1201
+ // one), copies its build into the site's output there, and prerender and the
1202
+ // dev server load it from the same place — the form the `extensions`
1203
+ // template declares.
1154
1204
  // ⛔ Until 2026-10-08 this wrote `/<folder>/dist/entry.js`, which that lookup
1155
1205
  // reads as an extension named after the folder's FIRST segment —
1156
1206
  // `/extensions/effects/dist/entry.js` as one named `extensions` — so the