uniweb 0.87.2 → 0.87.4

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.87.2",
3
+ "version": "0.87.4",
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.38.0",
45
- "@uniweb/schemas": "^0.13.0",
46
- "@uniweb/kit": "^0.20.0",
47
- "@uniweb/semantic-parser": "^1.5.1",
48
44
  "@uniweb/content-writer": "^0.3.4",
49
- "@uniweb/runtime": "^0.29.4"
45
+ "@uniweb/kit": "^0.20.0",
46
+ "@uniweb/schemas": "^0.13.0",
47
+ "@uniweb/runtime": "^0.29.4",
48
+ "@uniweb/core": "^0.38.0",
49
+ "@uniweb/semantic-parser": "^1.5.1"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@uniweb/content-reader": "^1.2.8",
53
- "@uniweb/build": "^0.80.2",
54
- "@uniweb/semantic-parser": "^1.5.1"
53
+ "@uniweb/semantic-parser": "^1.5.1",
54
+ "@uniweb/build": "^0.80.3"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -55,7 +55,8 @@ import {
55
55
  backfillLinkUuids,
56
56
  LINK_MODEL,
57
57
  bankedFileUuids,
58
- existingSectionFile
58
+ existingSectionFile,
59
+ layoutUnitFile
59
60
  } from '@uniweb/build/uwx'
60
61
 
61
62
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -312,9 +313,15 @@ export function unitFilesOf(siteDir, unitPath) {
312
313
  } catch {
313
314
  /* no or unreadable site.yml — the defaults are right */
314
315
  }
315
- for (const root of ['pages', 'layout']) {
316
- if (!unitPath.startsWith(`${root}/`)) continue
317
- const rel = `${paths[root] || root}${unitPath.slice(root.length)}`
316
+ // A layout unit names its layout and area (`footer.md`, `default/header/1-alert.md`); the author may
317
+ // keep it as `default/footer.md` or `default/header/0-alert.md`, where a pull writes it back
318
+ // (`layoutUnitFile`). ⛔ Until 2026-10-08 it was looked up beside the unit's own path only.
319
+ if (unitPath.startsWith('layout/')) {
320
+ const base = join(siteDir, paths.layout || 'layout')
321
+ return [relative(siteDir, layoutUnitFile(base, unitPath.slice('layout/'.length)))]
322
+ }
323
+ if (unitPath.startsWith('pages/')) {
324
+ const rel = `${paths.pages || 'pages'}${unitPath.slice('pages'.length)}`
318
325
  if (!rel.endsWith('.md') || existsSync(join(siteDir, rel))) return [rel]
319
326
  const id = basename(rel, '.md')
320
327
  const found = existingSectionFile(join(siteDir, dirname(rel)), id, id, { child: true })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-08T14:08:23.681Z",
3
+ "generatedAt": "2026-10-08T17:12:48.725Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.7.0",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.80.2",
13
+ "version": "0.80.3",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
package/src/index.js CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  formatOptions
73
73
  } from './utils/interactive.js'
74
74
  import { findWorkspaceRoot } from './utils/workspace.js'
75
+ import { exitWhenDrained } from './utils/exit.js'
75
76
 
76
77
  // Colors for terminal output
77
78
  const colors = {
@@ -223,7 +224,7 @@ function delegateToLocal(localCliPath) {
223
224
  [localCliPath, ...process.argv.slice(2)],
224
225
  { stdio: 'inherit' }
225
226
  )
226
- child.on('close', (code) => process.exit(code ?? 0))
227
+ child.on('close', (code) => exitWhenDrained(code ?? 0))
227
228
  child.on('error', reject)
228
229
  })
229
230
  }
@@ -320,7 +321,7 @@ async function importProjectCommand(modulePath) {
320
321
  log(` ${colors.cyan}npm install uniweb ${missing}${colors.reset}`)
321
322
  }
322
323
 
323
- process.exit(1)
324
+ await exitWhenDrained(1)
324
325
  }
325
326
  }
326
327
 
@@ -651,7 +652,7 @@ async function main() {
651
652
  // beside the new one it is ignored, so a script can carry both while CLIs of both ages run it.
652
653
  if (process.env.UNIWEB_REGISTER_URL && !process.env.UNIWEB_SERVER) {
653
654
  error('UNIWEB_REGISTER_URL is now UNIWEB_SERVER — rename it, and the command goes to the same backend.')
654
- process.exit(2)
655
+ await exitWhenDrained(2)
655
656
  }
656
657
 
657
658
  // Handle --version / -v
@@ -794,14 +795,14 @@ async function main() {
794
795
  if (command === 'doctor') {
795
796
  const { doctor } = await importProjectCommand('./commands/doctor.js')
796
797
  const result = await doctor(args.slice(1))
797
- process.exit(result?.errors > 0 ? 1 : 0)
798
+ await exitWhenDrained(result?.errors > 0 ? 1 : 0)
798
799
  }
799
800
 
800
801
  // Handle validate command (dynamic import — depends on @uniweb/build)
801
802
  if (command === 'validate') {
802
803
  const { validate } = await importProjectCommand('./commands/validate.js')
803
804
  const result = await validate(args.slice(1))
804
- process.exit(result?.exitCode ?? 0)
805
+ await exitWhenDrained(result?.exitCode ?? 0)
805
806
  }
806
807
 
807
808
  // Handle register command (dynamic import — depends on @uniweb/build).
@@ -810,35 +811,35 @@ async function main() {
810
811
  if (command === 'register' || command === 'release') {
811
812
  const { register } = await importProjectCommand('./commands/register.js')
812
813
  const result = await register(args.slice(1))
813
- process.exit(result?.exitCode ?? 0)
814
+ await exitWhenDrained(result?.exitCode ?? 0)
814
815
  }
815
816
 
816
817
  // Handle push command (dynamic import — depends on @uniweb/build)
817
818
  if (command === 'push') {
818
819
  const { push } = await importProjectCommand('./commands/push.js')
819
820
  const result = await push(args.slice(1))
820
- process.exit(result?.exitCode ?? 0)
821
+ await exitWhenDrained(result?.exitCode ?? 0)
821
822
  }
822
823
 
823
824
  // Handle pull command (dynamic import — depends on @uniweb/build)
824
825
  if (command === 'pull') {
825
826
  const { pull } = await importProjectCommand('./commands/pull.js')
826
827
  const result = await pull(args.slice(1))
827
- process.exit(result?.exitCode ?? 0)
828
+ await exitWhenDrained(result?.exitCode ?? 0)
828
829
  }
829
830
 
830
831
  // Handle sync command (refresh + push; both halves imported, never reimplemented)
831
832
  if (command === 'sync') {
832
833
  const { sync } = await importProjectCommand('./commands/sync.js')
833
834
  const result = await sync(args.slice(1))
834
- process.exit(result?.exitCode ?? 0)
835
+ await exitWhenDrained(result?.exitCode ?? 0)
835
836
  }
836
837
 
837
838
  // Handle refresh command (dynamic import — depends on @uniweb/build via pull)
838
839
  if (command === 'refresh') {
839
840
  const { refresh } = await importProjectCommand('./commands/refresh.js')
840
841
  const result = await refresh(args.slice(1))
841
- process.exit(result?.exitCode ?? 0)
842
+ await exitWhenDrained(result?.exitCode ?? 0)
842
843
  }
843
844
 
844
845
  // Handle forget — remove one backend's local records (sync.json, cache, deploy
@@ -847,14 +848,14 @@ async function main() {
847
848
  if (command === 'forget') {
848
849
  const { forget } = await importProjectCommand('./commands/forget.js')
849
850
  const result = await forget(args.slice(1))
850
- process.exit(result?.exitCode ?? 0)
851
+ await exitWhenDrained(result?.exitCode ?? 0)
851
852
  }
852
853
 
853
854
  // Handle status command (dynamic import — offline emit via @uniweb/build)
854
855
  if (command === 'status') {
855
856
  const { status } = await importProjectCommand('./commands/status.js')
856
857
  const result = await status(args.slice(1))
857
- process.exit(result?.exitCode ?? 0)
858
+ await exitWhenDrained(result?.exitCode ?? 0)
858
859
  }
859
860
 
860
861
  // Handle clone command (global — bootstraps a new project from a backend site;
@@ -864,7 +865,7 @@ async function main() {
864
865
  // `uniweb pull` after install.)
865
866
  if (command === 'clone') {
866
867
  const result = await clone(args.slice(1))
867
- process.exit(result?.exitCode ?? 0)
868
+ await exitWhenDrained(result?.exitCode ?? 0)
868
869
  }
869
870
 
870
871
  // Handle update command
@@ -899,7 +900,7 @@ async function main() {
899
900
  if (command === 'publish') {
900
901
  const { publish } = await importProjectCommand('./commands/publish.js')
901
902
  const result = await publish(args.slice(1))
902
- process.exit(result?.exitCode ?? 0)
903
+ await exitWhenDrained(result?.exitCode ?? 0)
903
904
  }
904
905
 
905
906
  // Handle deploy command — third-party hosts only (dynamic import — @uniweb/build)
@@ -946,7 +947,7 @@ async function main() {
946
947
  // dropped, the login would go to the default backend.
947
948
  if (loginArgs.some((a) => a === '--backend' || a.startsWith('--backend='))) {
948
949
  console.error('\x1b[31m✗\x1b[0m `--backend` is now `--server`: uniweb login --server <url>')
949
- process.exit(2)
950
+ await exitWhenDrained(2)
950
951
  }
951
952
  let apiBase
952
953
  try {
@@ -955,7 +956,7 @@ async function main() {
955
956
  console.error(
956
957
  `\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --server http://localhost:8080`
957
958
  )
958
- process.exit(2)
959
+ await exitWhenDrained(2)
959
960
  }
960
961
  await runRegistryLogin({ apiBase, args: loginArgs })
961
962
  return
@@ -971,7 +972,7 @@ async function main() {
971
972
  console.error(
972
973
  `\x1b[31m✗\x1b[0m \`uniweb logout\` takes no options (got ${stray}) — it logs you out of the backend you are logged in to.`
973
974
  )
974
- process.exit(2)
975
+ await exitWhenDrained(2)
975
976
  }
976
977
  const { clearRegistryAuth } = await import('./utils/registry-auth.js')
977
978
  const { loggedInOrigin } = await import('./utils/config.js')
@@ -994,12 +995,9 @@ async function main() {
994
995
  if (command === 'site') {
995
996
  const { site } = await import('./commands/site.js')
996
997
  const result = await site(args.slice(1))
997
- // ⛔ EXIT ONLY ONCE STDOUT HAS DRAINED. To a pipe Node writes asynchronously, so an exit
998
- // straight after a long list cut it at 8192 bytes — measured 2026-10-07: `site list --json`
999
- // of 79 sites reached a reading process as half a JSON document, while the same command
1000
- // into a file (synchronous) was whole. Pinned by test/site-list-pipe.test.js.
1001
- await new Promise((resolve) => process.stdout.write('', resolve))
1002
- process.exit(result?.exitCode ?? 0)
998
+ // Its long list is where a bare exit was first caught cutting a pipe short — pinned by
999
+ // test/site-list-pipe.test.js; why every exit waits: utils/exit.js.
1000
+ await exitWhenDrained(result?.exitCode ?? 0)
1003
1001
  }
1004
1002
 
1005
1003
  // Handle org command (new-backend orgs/units — publish-scope management)
@@ -1038,7 +1036,7 @@ async function main() {
1038
1036
  if (command !== 'create') {
1039
1037
  error(`Unknown command: ${command}`)
1040
1038
  showHelp()
1041
- process.exit(1)
1039
+ await exitWhenDrained(1)
1042
1040
  }
1043
1041
 
1044
1042
  title('Uniweb Project Generator')
@@ -1053,7 +1051,7 @@ async function main() {
1053
1051
  log(
1054
1052
  ` ${colors.cyan}uniweb add foundation --from <template>${colors.reset}\n`
1055
1053
  )
1056
- process.exit(1)
1054
+ await exitWhenDrained(1)
1057
1055
  }
1058
1056
 
1059
1057
  // Parse arguments
@@ -1081,7 +1079,7 @@ async function main() {
1081
1079
  parseTemplateId(templateType)
1082
1080
  } catch (err) {
1083
1081
  error(`Invalid template: ${err.message}`)
1084
- process.exit(1)
1082
+ await exitWhenDrained(1)
1085
1083
  }
1086
1084
  }
1087
1085
 
@@ -1127,7 +1125,7 @@ async function main() {
1127
1125
  `Could not derive a valid project name from the current directory ("${dirName}").`
1128
1126
  )
1129
1127
  log(`Re-run with ${colors.cyan}--name=<your-name>${colors.reset}.`)
1130
- process.exit(1)
1128
+ await exitWhenDrained(1)
1131
1129
  }
1132
1130
  projectName = slug
1133
1131
  if (slug !== dirName) {
@@ -1144,7 +1142,7 @@ async function main() {
1144
1142
  if (nonInteractive && !projectName) {
1145
1143
  error(`Missing project name.\n`)
1146
1144
  log(`Usage: ${prefix} create <project-name> [--template <name>] [--blank]`)
1147
- process.exit(1)
1145
+ await exitWhenDrained(1)
1148
1146
  }
1149
1147
 
1150
1148
  // Non-interactive: default to starter when no template specified
@@ -1172,7 +1170,7 @@ async function main() {
1172
1170
  {
1173
1171
  onCancel: () => {
1174
1172
  log('\nScaffolding cancelled.')
1175
- process.exit(0)
1173
+ process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
1176
1174
  }
1177
1175
  }
1178
1176
  )
@@ -1181,7 +1179,7 @@ async function main() {
1181
1179
 
1182
1180
  if (!projectName) {
1183
1181
  error('Missing project name')
1184
- process.exit(1)
1182
+ await exitWhenDrained(1)
1185
1183
  }
1186
1184
 
1187
1185
  // Prompt for template if not specified via --template or --blank
@@ -1197,7 +1195,7 @@ async function main() {
1197
1195
  {
1198
1196
  onCancel: () => {
1199
1197
  log('\nScaffolding cancelled.')
1200
- process.exit(0)
1198
+ process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
1201
1199
  }
1202
1200
  }
1203
1201
  )
@@ -1219,7 +1217,7 @@ async function main() {
1219
1217
 
1220
1218
  if (!inPlace && existsSync(projectDir)) {
1221
1219
  error(`Directory already exists: ${projectName}`)
1222
- process.exit(1)
1220
+ await exitWhenDrained(1)
1223
1221
  }
1224
1222
 
1225
1223
  if (inPlace) {
@@ -1245,7 +1243,7 @@ async function main() {
1245
1243
  log(
1246
1244
  `Move or remove them, then re-run ${colors.cyan}uniweb create .${colors.reset}.`
1247
1245
  )
1248
- process.exit(1)
1246
+ await exitWhenDrained(1)
1249
1247
  }
1250
1248
  }
1251
1249
 
@@ -1320,7 +1318,7 @@ async function main() {
1320
1318
  log(
1321
1319
  ` • Try the starter template instead: ${colors.cyan}uniweb create ${projectName} --template starter${colors.reset}`
1322
1320
  )
1323
- process.exit(1)
1321
+ await exitWhenDrained(1)
1324
1322
  }
1325
1323
  }
1326
1324
 
@@ -2231,7 +2229,7 @@ ${colors.bright}Install:${colors.reset}
2231
2229
  }
2232
2230
 
2233
2231
  // Run CLI
2234
- main().catch((err) => {
2232
+ main().catch(async (err) => {
2235
2233
  error(err.message)
2236
- process.exit(1)
2234
+ await exitWhenDrained(1)
2237
2235
  })
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Exit once everything this process wrote to stdout and stderr has left it.
3
+ *
4
+ * ⛔ `process.exit()` does not wait for output a pipe has not taken yet. On macOS Node writes
5
+ * to a pipe asynchronously, and an exit drops whatever is still queued: `uniweb site list
6
+ * --json` reached a reading process as half a JSON document, cut at 8192 bytes, and a single
7
+ * 100 000-byte write followed by an exit delivered 65 536 — on stdout and on stderr alike
8
+ * (measured 2026-10-07). Into a terminal or a file, or on Linux, the writes are synchronous
9
+ * and nothing is lost, which is how it went unseen: the reader it bites is a script, `| jq`,
10
+ * or an agent capturing the output.
11
+ *
12
+ * ⭐ An empty write's callback runs once every write before it on that stream has been handed
13
+ * over, so waiting for one per stream is enough.
14
+ *
15
+ * Every exit in `index.js` goes through here — `test/exit-drain.test.js` holds it to that.
16
+ *
17
+ * @param {number} [code]
18
+ * @returns {Promise<never>}
19
+ */
20
+ export async function exitWhenDrained(code = 0) {
21
+ await Promise.all([process.stdout, process.stderr].map(drained))
22
+ process.exit(code)
23
+ }
24
+
25
+ function drained(stream) {
26
+ return new Promise((resolve) => {
27
+ if (!stream || stream.destroyed || stream.writableEnded) return resolve()
28
+ stream.write('', () => resolve())
29
+ })
30
+ }