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 +5 -5
- package/src/commands/rename.js +50 -21
- package/src/framework-index.json +4 -4
- package/src/utils/scaffold.js +44 -11
- package/src/utils/yaml-edit.js +115 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.56.
|
|
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.
|
|
47
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/schemas": "^0.3.3",
|
|
47
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
48
48
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
49
|
-
"@uniweb/
|
|
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": {
|
package/src/commands/rename.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
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.
|
|
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.
|
|
114
|
+
"version": "0.1.1",
|
|
115
115
|
"path": "framework/snapshot",
|
|
116
116
|
"deps": []
|
|
117
117
|
},
|
|
118
118
|
"@uniweb/templates": {
|
|
119
|
-
"version": "0.14.
|
|
119
|
+
"version": "0.14.8",
|
|
120
120
|
"path": "framework/templates",
|
|
121
121
|
"deps": []
|
|
122
122
|
},
|
package/src/utils/scaffold.js
CHANGED
|
@@ -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
|
-
//
|
|
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': [
|
|
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
|
|
271
|
-
//
|
|
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
|
|
292
|
-
//
|
|
293
|
-
// (notably `docs/site/site.yml.hbs`) omit
|
|
294
|
-
// and dropping it leaves the site without a
|
|
295
|
-
// the entry's `import '#foundation/styles'` fails
|
|
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
|
+
}
|