uniweb 0.56.8 → 0.56.10

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.8",
3
+ "version": "0.56.10",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,14 +43,14 @@
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.29.4",
45
45
  "@uniweb/kit": "^0.19.3",
46
- "@uniweb/schemas": "^0.3.1",
47
- "@uniweb/runtime": "^0.26.4",
46
+ "@uniweb/schemas": "^0.3.3",
47
+ "@uniweb/content-writer": "^0.3.4",
48
48
  "@uniweb/semantic-parser": "^1.4.1",
49
- "@uniweb/content-writer": "^0.3.4"
49
+ "@uniweb/runtime": "^0.26.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.52.6",
53
52
  "@uniweb/content-reader": "^1.2.5",
53
+ "@uniweb/build": "^0.52.6",
54
54
  "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -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-16T23:53:23.238Z",
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.1",
99
+ "version": "0.3.3",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },
@@ -111,12 +111,12 @@
111
111
  "deps": []
112
112
  },
113
113
  "@uniweb/snapshot": {
114
- "version": "0.1.0",
114
+ "version": "0.1.1",
115
115
  "path": "framework/snapshot",
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.14.6",
119
+ "version": "0.14.8",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -178,10 +178,22 @@ export async function applyContent(
178
178
  '.gitignore'
179
179
  ])
180
180
 
181
- // Config files that should be merged, not overwritten.
182
- // Keys listed here are preserved from the scaffolded version.
181
+ // Config files that should be merged, not overwritten: for each listed
182
+ // key, when the scaffolded version's value is used.
183
+ //
184
+ // foundation ALWAYS — the CLI resolved it for this project, and a
185
+ // content template cannot know it.
186
+ // name ONLY WHEN THE TEMPLATE SETS NONE. A template's own name is
187
+ // the default name of a site made from it — the same thing
188
+ // cloning a template in an app gives — so a literal
189
+ // `name: Product Launch` survives, and the project name fills
190
+ // in for a template with no name, or only the
191
+ // `{{projectName}}` placeholder.
183
192
  const MERGE_FILES = {
184
- 'site.yml': ['name', 'foundation']
193
+ 'site.yml': [
194
+ ['name', 'when-template-has-none'],
195
+ ['foundation', 'always']
196
+ ]
185
197
  }
186
198
 
187
199
  await copyContentRecursive(
@@ -267,8 +279,9 @@ async function copyContentRecursive(
267
279
  // educational structure of the content template survive) and
268
280
  // override only the specific top-level keys listed in
269
281
  // preserveKeys with the values from the already-scaffolded base
270
- // file (so the user's chosen project name and foundation ref
271
- // don't get replaced by whatever the content template hardcoded).
282
+ // file (so the resolved foundation ref is never replaced by whatever
283
+ // the content template hardcoded, and a project name fills in for a
284
+ // template that names nothing).
272
285
  //
273
286
  // Earlier versions of this code parsed both files through
274
287
  // js-yaml, merged the objects, and re-emitted the result via
@@ -283,17 +296,21 @@ async function copyContentRecursive(
283
296
  const existing = yaml.load(existingContent) || {}
284
297
  let merged = newContent ?? (await fs.readFile(sourcePath, 'utf-8'))
285
298
 
286
- for (const key of preserveKeys) {
299
+ for (const [key, when] of preserveKeys) {
287
300
  if (existing[key] === undefined) continue
288
301
  const baseLine = matchTopLevelLine(existingContent, key)
289
302
  if (!baseLine) continue
290
303
  // If the new content carries the key, replace its line with
291
- // the scaffolded value (preserving the user's project/foundation
292
- // choice). Otherwise insert the line — older content templates
293
- // (notably `docs/site/site.yml.hbs`) omit `foundation:` entirely,
294
- // and dropping it leaves the site without a foundation ref so
295
- // the entry's `import '#foundation/styles'` fails at build time.
304
+ // the scaffolded value unless the key only fills in, and the
305
+ // template set a value of its own. Otherwise insert the line —
306
+ // older content templates (notably `docs/site/site.yml.hbs`) omit
307
+ // `foundation:` entirely, and dropping it leaves the site without a
308
+ // foundation ref so the entry's `import '#foundation/styles'` fails
309
+ // at build time.
296
310
  if (matchTopLevelLine(merged, key)) {
311
+ if (when === 'when-template-has-none' && hasOwnValue(merged, key)) {
312
+ continue
313
+ }
297
314
  merged = replaceTopLevelLine(merged, key, baseLine)
298
315
  } else {
299
316
  merged = insertTopLevelLine(merged, baseLine)
@@ -358,6 +375,22 @@ function escapeRegex(s) {
358
375
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
359
376
  }
360
377
 
378
+ /**
379
+ * Whether a content template sets `key` to a value of its own: a non-empty
380
+ * string that is not an unrendered `{{placeholder}}` (a plain `site.yml`, not
381
+ * `.hbs`, is copied without rendering). Content that does not parse counts as
382
+ * setting nothing, so the scaffolded value fills in, as it always did.
383
+ */
384
+ function hasOwnValue(content, key) {
385
+ let value
386
+ try {
387
+ value = (yaml.load(content) || {})[key]
388
+ } catch {
389
+ return false
390
+ }
391
+ return typeof value === 'string' && value.trim() !== '' && !value.includes('{{')
392
+ }
393
+
361
394
  /**
362
395
  * Find the verbatim text of a single-line top-level YAML entry like
363
396
  * `name: foo bar` or `foundation: my-foundation`. Returns the matched
@@ -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
+ }