uniweb 0.56.9 → 0.56.11

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.56.9",
3
+ "version": "0.56.11",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,11 +41,11 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/content-writer": "^0.3.4",
44
45
  "@uniweb/core": "^0.29.4",
45
46
  "@uniweb/runtime": "^0.26.4",
47
+ "@uniweb/schemas": "^0.3.3",
46
48
  "@uniweb/semantic-parser": "^1.4.1",
47
- "@uniweb/schemas": "^0.3.2",
48
- "@uniweb/content-writer": "^0.3.4",
49
49
  "@uniweb/kit": "^0.19.3"
50
50
  },
51
51
  "peerDependencies": {
@@ -40,6 +40,7 @@ import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
40
40
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
41
  import { readFlagValue } from '../utils/args.js'
42
42
  import { isNonInteractive } from '../utils/interactive.js'
43
+ import { compareSemverPrecedence } from '../utils/semver-precedence.js'
43
44
 
44
45
  /**
45
46
  * Resolve the site's LOCAL foundation — the one publish should bring along — or
@@ -117,13 +118,19 @@ export function resolveLocalExtensions(siteDir, siteYml) {
117
118
  }
118
119
 
119
120
  // The foundation's scoped catalog name (`@org/name`) from its package.json — an
120
- // already-scoped `name`, else `uniweb.scope` + a bare `name`. Null when neither
121
+ // already-scoped name, else `uniweb.scope` + a bare one. Null when neither
121
122
  // yields a scoped name (then we can't look up the registered version, so the
122
123
  // caller treats the foundation as "release it and let register pick the scope").
124
+ //
125
+ // ⛔ The name is `uniweb.id` when set, exactly as the build reads it for the
126
+ // schema that `register` submits (`build/src/schema.js`). Reading `name` alone —
127
+ // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
128
+ // the catalog does not have, so every push re-released it, and pinned the site
129
+ // to that same wrong name.
123
130
  function foundationScopedName(dir) {
124
131
  try {
125
132
  const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
126
- const name = pkg?.name
133
+ const name = pkg?.uniweb?.id || pkg?.name
127
134
  if (typeof name === 'string' && name.startsWith('@')) return name
128
135
  const scope = pkg?.uniweb?.scope
129
136
  if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
@@ -344,8 +351,18 @@ async function bringLocalCodeAlong({
344
351
  )
345
352
  return { released: false, proceed: true, ref: registeredRef(reg) }
346
353
  }
354
+ // ⚖️ The registry takes a NEW version only when it is greater than every one
355
+ // it holds — but an older local version may be one it already has, and
356
+ // re-registering that resumes. The CLI cannot tell which from here, so it
357
+ // does not refuse: it submits, says what will decide, and `register` prints
358
+ // the registry's answer if it is a no.
359
+ const order = compareSemverPrecedence(local.version, reg.latest_version)
347
360
  say.info(
348
- `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
361
+ order === 1
362
+ ? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
363
+ : order === null
364
+ ? `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
365
+ : `Releasing the ${kind} ${label} — not newer than the registered latest ${reg.latest_version}, so the registry takes it only if ${local.version} is already registered with this code…`
349
366
  )
350
367
  return {
351
368
  released: releaseFoundation(local, args, cliBin, say),
@@ -633,8 +633,8 @@ async function runRegister(args = []) {
633
633
  // Resume path: a registered version is immutable, so re-running after a
634
634
  // partial code delivery hits the duplicate rejection here — a STRUCTURED
635
635
  // 409 (problem+json, title "Conflict") — and proceeds to phase 2 (the
636
- // code-uploads plan authorizes against the REGISTERED version; completed
637
- // files are idempotent no-ops).
636
+ // code-uploads plan authorizes against the REGISTERED version; files it
637
+ // already stores come back `present` and are skipped).
638
638
  const isDuplicate = !standalone && res.status === 409
639
639
  if (isDuplicate) {
640
640
  alreadyRegistered = true
@@ -712,14 +712,18 @@ async function runRegister(args = []) {
712
712
  )
713
713
  }
714
714
  log(
715
- ` ${colors.dim}Re-run \`uniweb register\` to resume — completed files are safe no-ops.${colors.reset}`
715
+ ` ${colors.dim}Re-run \`uniweb register\` to resume — files already stored are skipped.${colors.reset}`
716
716
  )
717
717
  return { exitCode: 1 }
718
718
  }
719
+ // "4 files" on a first upload; "1 uploaded, 3 already stored" on a resume.
720
+ const delivered = result.stored?.length
721
+ ? `${result.uploaded.length} uploaded, ${result.stored.length} already stored`
722
+ : `${result.uploaded.length} files`
719
723
  if (result.verified === true) {
720
724
  // serveBase is guaranteed here — it is what gated the check.
721
725
  success(
722
- `Code delivered (${result.uploaded.length} files) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
726
+ `Code delivered (${delivered}) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
723
727
  )
724
728
  } else if (result.verified === false) {
725
729
  error(
@@ -727,9 +731,7 @@ async function runRegister(args = []) {
727
731
  )
728
732
  return { exitCode: 1 }
729
733
  } else {
730
- success(
731
- `Code delivered (${result.uploaded.length} files, ${result.mode} mode)`
732
- )
734
+ success(`Code delivered (${delivered}, ${result.mode} mode)`)
733
735
  // Say WHY nothing was checked, rather than skipping in silence. The CLI
734
736
  // never reconstructs a serve URL (see utils/code-upload.js), so a plan
735
737
  // without `serve_base` means the location is not ours to know — name it
@@ -741,6 +743,16 @@ async function runRegister(args = []) {
741
743
  }
742
744
  }
743
745
  } catch (err) {
746
+ // ⚖️ A 4xx is the registry REFUSING the plan — e.g. a stored file
747
+ // declared at a different size (`version_content_changed`). Running the
748
+ // same command again gets the same answer, so do not suggest it: print
749
+ // the refusal's own sentence, which says what does help.
750
+ if (err.status >= 400 && err.status < 500 && err.detail) {
751
+ error(`Code delivery refused: HTTP ${err.status}`)
752
+ log(` ${err.detail}`)
753
+ if (err.code) log(` ${colors.dim}(${err.code})${colors.reset}`)
754
+ return { exitCode: 1 }
755
+ }
744
756
  error(`Code delivery failed: ${err.message}`)
745
757
  log(
746
758
  ` ${colors.dim}The schema registration above succeeded; re-run \`uniweb register\` to deliver the code.${colors.reset}`
@@ -60,6 +60,7 @@ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
60
60
  import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
61
61
  import { detectPackageManager, installCmd } from '../utils/pm.js'
62
62
  import { getCliPrefix } from '../utils/interactive.js'
63
+ import { replaceInTopLevelList, setTopLevelScalar } from '../utils/yaml-edit.js'
63
64
 
64
65
  const colors = {
65
66
  reset: '\x1b[0m',
@@ -208,6 +209,24 @@ async function rewritePackageJsonName(pkgPath, newName) {
208
209
  await writeJsonPreservingStyleAsync(pkgPath, pkg, src)
209
210
  }
210
211
 
212
+ /**
213
+ * Stop, before any file moves, when a site's `site.yml` names the package in a
214
+ * form the in-place edit cannot reach. The alternative is rewriting the whole
215
+ * file, which loses the author's comments and formatting — so we refuse and say
216
+ * how to write it instead.
217
+ */
218
+ function refuseUneditableSiteYml(sitePaths, key, hint) {
219
+ if (sitePaths.length === 0) return
220
+ for (const path of sitePaths) {
221
+ error(
222
+ `Cannot rename: ${colors.bright}${path}/site.yml${colors.reset} writes \`${key}:\` in a form this command cannot edit without rewriting the file.`
223
+ )
224
+ }
225
+ log(`Nothing was changed. ${hint}`)
226
+ log('Then run the rename again.')
227
+ process.exit(1)
228
+ }
229
+
211
230
  // ─── Foundation rename ───────────────────────────────────────────
212
231
 
213
232
  async function renameFoundation(rootDir, oldName, newName, prefix) {
@@ -262,7 +281,7 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
262
281
  for (const site of sites) {
263
282
  const sitePkgPath = join(rootDir, site.path, 'package.json')
264
283
  const siteYmlPath = join(rootDir, site.path, 'site.yml')
265
- let pkg, pkgSrc, ymlData
284
+ let pkg, pkgSrc, ymlText, ymlData
266
285
  try {
267
286
  pkgSrc = await readFile(sitePkgPath, 'utf-8')
268
287
  pkg = JSON.parse(pkgSrc)
@@ -271,7 +290,8 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
271
290
  pkgSrc = null
272
291
  }
273
292
  try {
274
- ymlData = yaml.load(await readFile(siteYmlPath, 'utf-8')) || {}
293
+ ymlText = await readFile(siteYmlPath, 'utf-8')
294
+ ymlData = yaml.load(ymlText) || {}
275
295
  } catch {
276
296
  ymlData = null
277
297
  }
@@ -285,12 +305,19 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
285
305
  pkgSrc,
286
306
  sitePkgPath,
287
307
  siteYmlPath,
288
- ymlData,
308
+ // Edited in place — see utils/yaml-edit.js — and computed HERE, before
309
+ // anything moves, so a file it cannot edit stops the rename cleanly.
310
+ newYmlText: ymlMatches ? setTopLevelScalar(ymlText, 'foundation', newName) : null,
289
311
  hasDep,
290
312
  ymlMatches
291
313
  })
292
314
  }
293
315
  }
316
+ refuseUneditableSiteYml(
317
+ affectedSites.filter((s) => s.ymlMatches && s.newYmlText === null).map((s) => s.path),
318
+ 'foundation',
319
+ `Write it on one line: \`foundation: ${oldName}\`.`
320
+ )
294
321
 
295
322
  // ─── Print plan, then execute ────────────────────────────────
296
323
 
@@ -333,11 +360,7 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
333
360
  await writeJsonPreservingStyleAsync(s.sitePkgPath, s.pkg, s.pkgSrc)
334
361
  }
335
362
  if (s.ymlMatches) {
336
- const newYmlData = { ...s.ymlData, foundation: newName }
337
- await writeFile(
338
- s.siteYmlPath,
339
- yaml.dump(newYmlData, { flowLevel: -1, quotingType: "'" })
340
- )
363
+ await writeFile(s.siteYmlPath, s.newYmlText)
341
364
  }
342
365
  }
343
366
 
@@ -485,9 +508,10 @@ async function renameExtension(rootDir, oldName, newName, prefix) {
485
508
  const affectedSites = []
486
509
  for (const site of sites) {
487
510
  const siteYmlPath = join(rootDir, site.path, 'site.yml')
488
- let ymlData
511
+ let ymlText, ymlData
489
512
  try {
490
- ymlData = yaml.load(await readFile(siteYmlPath, 'utf-8')) || {}
513
+ ymlText = await readFile(siteYmlPath, 'utf-8')
514
+ ymlData = yaml.load(ymlText) || {}
491
515
  } catch {
492
516
  continue
493
517
  }
@@ -496,9 +520,23 @@ async function renameExtension(rootDir, oldName, newName, prefix) {
496
520
  (e) => typeof e === 'string' && e.startsWith(oldUrlPrefix)
497
521
  )
498
522
  if (hits.length > 0) {
499
- affectedSites.push({ site, ymlData, siteYmlPath, hits })
523
+ const replacements = new Map(
524
+ hits.map((e) => [e, newUrlPrefix + e.slice(oldUrlPrefix.length)])
525
+ )
526
+ affectedSites.push({
527
+ site,
528
+ siteYmlPath,
529
+ hits,
530
+ // Edited in place, and computed before anything moves — as for foundations.
531
+ newYmlText: replaceInTopLevelList(ymlText, 'extensions', replacements)
532
+ })
500
533
  }
501
534
  }
535
+ refuseUneditableSiteYml(
536
+ affectedSites.filter((a) => a.newYmlText === null).map((a) => a.site.path),
537
+ 'extensions',
538
+ `Write each entry on a line of its own, e.g. \`- ${oldUrlPrefix}dist/entry.js\`.`
539
+ )
502
540
 
503
541
  log('')
504
542
  log(
@@ -531,16 +569,7 @@ async function renameExtension(rootDir, oldName, newName, prefix) {
531
569
  await rewritePackageJsonName(join(newExtDir, 'package.json'), newName)
532
570
 
533
571
  for (const a of affectedSites) {
534
- const newExts = (a.ymlData.extensions || []).map((e) =>
535
- typeof e === 'string' && e.startsWith(oldUrlPrefix)
536
- ? newUrlPrefix + e.slice(oldUrlPrefix.length)
537
- : e
538
- )
539
- const newYmlData = { ...a.ymlData, extensions: newExts }
540
- await writeFile(
541
- a.siteYmlPath,
542
- yaml.dump(newYmlData, { flowLevel: -1, quotingType: "'" })
543
- )
572
+ await writeFile(a.siteYmlPath, a.newYmlText)
544
573
  }
545
574
 
546
575
  if (folderWillRename) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-17T03:24:18.741Z",
3
+ "generatedAt": "2026-09-17T03:53:59.364Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.3.6",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.3.2",
99
+ "version": "0.3.3",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.14.7",
119
+ "version": "0.14.8",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -7,10 +7,12 @@
7
7
  *
8
8
  * 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
9
9
  * ({ path, content_type, size, sha256? }). The response carries
10
- * one upload target per file ({ path, method, url, headers })
11
- * plus mode: 'direct' (dev URLs point back at the backend) or
12
- * 'presigned' (prod storage PUTs; bytes never transit the
13
- * backend). The CLI never branches on the mode.
10
+ * one entry per file: `present: true` for a file the backend
11
+ * already stores for this version (no URL it is skipped), or an
12
+ * upload target ({ path, method, url, headers }). Plus mode:
13
+ * 'direct' (dev URLs point back at the backend) or 'presigned'
14
+ * (prod — storage PUTs; bytes never transit the backend). The CLI
15
+ * never branches on the mode.
14
16
  * 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
15
17
  * The ENTRY uploads LAST: a partial upload never yields a
16
18
  * loadable version (practical atomicity — there is no server
@@ -35,7 +37,8 @@
35
37
  * plan step's per-version file cap — the cap is an abuse guard, the maps
36
38
  * simply don't belong on the CDN.)
37
39
  * - a registered version is immutable, code included — changed bytes mean
38
- * a new version (re-PUTting identical bytes is a safe no-op)
40
+ * a new version. A stored file is never re-sent (the plan marks it
41
+ * `present`), and one declared at a different size is refused (422)
39
42
  */
40
43
 
41
44
  import { createHash } from 'node:crypto'
@@ -215,7 +218,10 @@ export function computeFoundationDigest(distDir) {
215
218
  * @param {string} opts.distDir - the built dist/ directory
216
219
  * @param {Array} [opts.files] - pre-collected file list (default: collect)
217
220
  * @param {(msg: string) => void} [opts.onProgress]
218
- * @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
221
+ * @returns {Promise<{ mode: string, uploaded: string[], stored: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
222
+ * `stored` — files the plan reported as already stored (`present: true`), not re-sent.
223
+ * @throws {Error} when the plan is refused — with `status`, and the problem+json
224
+ * `detail` and `code` when the body carries them
219
225
  */
220
226
  export async function uploadFoundationCode({
221
227
  apiBase,
@@ -259,10 +265,22 @@ export async function uploadFoundationCode({
259
265
  })
260
266
  if (!planRes.ok) {
261
267
  const body = await planRes.text().catch(() => '')
268
+ // A refusal is problem+json, and its `detail` is a sentence written for the
269
+ // person reading — for a changed file it says what to do. Carry it (and the
270
+ // `code`) instead of a raw body the reader has to dig the sentence out of.
271
+ let problem = null
272
+ try {
273
+ problem = JSON.parse(body)
274
+ } catch {
275
+ problem = null
276
+ }
277
+ const detail = typeof problem?.detail === 'string' ? problem.detail : null
262
278
  const err = new Error(
263
- `code-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
279
+ `code-uploads plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail}` : body ? ` — ${body.slice(0, 300)}` : ''}`
264
280
  )
265
281
  err.status = planRes.status
282
+ err.detail = detail
283
+ err.code = typeof problem?.code === 'string' ? problem.code : null
266
284
  throw err
267
285
  }
268
286
  const plan = await planRes.json()
@@ -275,6 +293,7 @@ export async function uploadFoundationCode({
275
293
  plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
276
294
 
277
295
  const uploaded = []
296
+ const stored = []
278
297
  const failed = []
279
298
  for (const file of uploadOrder(list)) {
280
299
  const target = targets.get(file.path)
@@ -286,6 +305,27 @@ export async function uploadFoundationCode({
286
305
  })
287
306
  continue
288
307
  }
308
+ // ⭐ A file the backend already stores for this version comes back
309
+ // `present: true`, with no URL — a stored file of a registered version is
310
+ // never overwritten. Skip it. This is what lets an interrupted upload
311
+ // resume, and a re-run on a fully uploaded version pass.
312
+ //
313
+ // ⛔ Before this branch existed, a present entry was PUT to
314
+ // `new URL(undefined, origin)`, which does not throw — it resolves to
315
+ // `<origin>/undefined` — and came back as a failed upload.
316
+ if (target.present === true) {
317
+ stored.push(file.path)
318
+ onProgress(`${file.path} (already stored)`)
319
+ continue
320
+ }
321
+ if (!target.url) {
322
+ failed.push({
323
+ path: file.path,
324
+ status: 0,
325
+ detail: 'the plan gave no upload URL'
326
+ })
327
+ continue
328
+ }
289
329
  const bytes = readFileSync(join(distDir, file.path))
290
330
  try {
291
331
  // ⭐ **Retried.** A single connection-level failure used to fail the whole
@@ -350,5 +390,5 @@ export async function uploadFoundationCode({
350
390
  }
351
391
  }
352
392
 
353
- return { mode: plan.mode || 'direct', uploaded, failed, verified, serveBase }
393
+ return { mode: plan.mode || 'direct', uploaded, stored, failed, verified, serveBase }
354
394
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SemVer 2.0.0 precedence — how a registry orders a foundation's versions.
3
+ *
4
+ * A pre-release sorts below its release (`1.0.0-rc.1` < `1.0.0`), and build
5
+ * metadata carries no order (`1.2.0+b` equals `1.2.0`).
6
+ *
7
+ * ⚖️ Not `compareSemver` in `dep-survey.js`: that one reads dependency specs
8
+ * (`^1.2.3`) for `uniweb update` and compares major.minor.patch only. This one
9
+ * is strict — anything that is not a SemVer version is `null`, never a guess.
10
+ */
11
+
12
+ // The regex published with the SemVer 2.0.0 specification (semver.org, §FAQ).
13
+ const SEMVER =
14
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
15
+
16
+ /**
17
+ * @param {unknown} version
18
+ * @returns {{ major: number, minor: number, patch: number, pre: string[] }|null}
19
+ */
20
+ export function parseSemver(version) {
21
+ if (typeof version !== 'string') return null
22
+ const m = SEMVER.exec(version)
23
+ if (!m) return null
24
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
25
+ }
26
+
27
+ /**
28
+ * @param {string} a
29
+ * @param {string} b
30
+ * @returns {-1|0|1|null} how `a` sorts against `b`; null when either is not SemVer
31
+ */
32
+ export function compareSemverPrecedence(a, b) {
33
+ const x = parseSemver(a)
34
+ const y = parseSemver(b)
35
+ if (!x || !y) return null
36
+ for (const part of ['major', 'minor', 'patch']) {
37
+ if (x[part] !== y[part]) return x[part] > y[part] ? 1 : -1
38
+ }
39
+ // A version with a pre-release sorts below the same version without one.
40
+ if (!x.pre.length || !y.pre.length) {
41
+ return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1
42
+ }
43
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
44
+ const p = x.pre[i]
45
+ const q = y.pre[i]
46
+ if (p === undefined) return -1
47
+ if (q === undefined) return 1
48
+ if (p === q) continue
49
+ const pNum = /^\d+$/.test(p)
50
+ const qNum = /^\d+$/.test(q)
51
+ // Numeric identifiers compare numerically and sort below alphanumeric ones.
52
+ if (pNum && qNum) return BigInt(p) > BigInt(q) ? 1 : -1
53
+ if (pNum !== qNum) return pNum ? -1 : 1
54
+ return p > q ? 1 : -1
55
+ }
56
+ return 0
57
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * In-place edits to a YAML file a person wrote (`site.yml`), for a command that
3
+ * must change one value and leave everything else as it was: comments, key
4
+ * order, blank lines, quoting, a flow list written on one line.
5
+ *
6
+ * ⛔ Never load a hand-written file and dump it back. js-yaml's `dump` drops every
7
+ * comment and re-flows lists and long strings — `scaffold.js` records what that
8
+ * did to the templates whose comments are the point of them.
9
+ *
10
+ * ⭐ Every edit is VERIFIED: the edited text must parse to exactly the old data
11
+ * with that one value changed. A value written in a form the line-level edit
12
+ * cannot reach (a block scalar, an entry split across lines) returns null
13
+ * rather than a guess, so the caller can refuse before it changes anything.
14
+ */
15
+
16
+ import { isDeepStrictEqual } from 'node:util'
17
+ import yaml from 'js-yaml'
18
+
19
+ const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
20
+
21
+ /** A scalar as written after `key: `, quoted only when YAML needs it (`@scope/name` does). */
22
+ const scalar = (value) => yaml.dump(value, { lineWidth: -1 }).trim()
23
+
24
+ /**
25
+ * Where a trailing ` # comment` begins in the text after `key:`, outside quotes,
26
+ * including the whitespace before it. -1 when the line has none.
27
+ */
28
+ function commentStart(rest) {
29
+ let quote = null
30
+ for (let i = 0; i < rest.length; i++) {
31
+ const ch = rest[i]
32
+ if (quote) {
33
+ if (ch === quote) quote = null
34
+ } else if (ch === '"' || ch === "'") {
35
+ quote = ch
36
+ } else if (ch === '#' && i > 0 && /\s/.test(rest[i - 1])) {
37
+ let start = i
38
+ while (start > 0 && /\s/.test(rest[start - 1])) start--
39
+ return start
40
+ }
41
+ }
42
+ return -1
43
+ }
44
+
45
+ /** The edited text when it parses to `expected`, else null. */
46
+ function verified(after, expected) {
47
+ try {
48
+ return isDeepStrictEqual(yaml.load(after) ?? {}, expected) ? after : null
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ function load(text) {
55
+ try {
56
+ const data = yaml.load(text) ?? {}
57
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Set a top-level, one-line scalar `key` to `value`, keeping the line's inline
65
+ * comment and every other line as it was.
66
+ *
67
+ * @param {string} text - the file's contents
68
+ * @param {string} key - a top-level key, e.g. `foundation`
69
+ * @param {string} value
70
+ * @returns {string|null} the new contents, or null when the edit cannot be made in place
71
+ */
72
+ export function setTopLevelScalar(text, key, value) {
73
+ const data = load(text)
74
+ if (!data) return null
75
+ const match = new RegExp(`^${escapeRegex(key)}:([^\\n]*)$`, 'm').exec(text)
76
+ if (!match) return null
77
+ const at = commentStart(match[1])
78
+ const comment = at === -1 ? '' : match[1].slice(at)
79
+ const line = `${key}: ${scalar(value)}${comment}`
80
+ const after = text.slice(0, match.index) + line + text.slice(match.index + match[0].length)
81
+ return verified(after, { ...data, [key]: value })
82
+ }
83
+
84
+ /**
85
+ * Replace entries of a top-level list `key` — each `from` value becomes its `to`
86
+ * — wherever an entry is written on a line of its own or inside a one-line flow
87
+ * list, plain or quoted. A value that only CONTAINS `from` is left alone, and so
88
+ * is a comment line.
89
+ *
90
+ * @param {string} text - the file's contents
91
+ * @param {string} key - a top-level list key, e.g. `extensions`
92
+ * @param {Map<string, string>} replacements - old entry → new entry
93
+ * @returns {string|null} the new contents, or null when the edit cannot be made in place
94
+ */
95
+ export function replaceInTopLevelList(text, key, replacements) {
96
+ const data = load(text)
97
+ if (!data || !Array.isArray(data[key])) return null
98
+ const expected = { ...data, [key]: data[key].map((v) => (replacements.has(v) ? replacements.get(v) : v)) }
99
+
100
+ const after = text
101
+ .split('\n')
102
+ .map((line) => {
103
+ if (/^\s*#/.test(line)) return line
104
+ for (const [from, to] of replacements) {
105
+ const f = escapeRegex(from)
106
+ line = line
107
+ .replace(new RegExp(`'${f}'`, 'g'), () => `'${to.replace(/'/g, "''")}'`)
108
+ .replace(new RegExp(`"${f}"`, 'g'), () => JSON.stringify(to))
109
+ .replace(new RegExp(`(^|[\\s\\[,])${f}(?=$|[\\s,\\]])`, 'g'), (_, lead) => lead + to)
110
+ }
111
+ return line
112
+ })
113
+ .join('\n')
114
+ return verified(after, expected)
115
+ }