From 8fd3a98580e501c1638d10d4cb3c451763018389 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 2 Aug 2026 15:16:08 +0100 Subject: [PATCH] perf(info,upgrade): use `confbox` rather than `pnpm-workspace-yaml` --- packages/nuxt-cli/package.json | 1 - packages/nuxt-cli/src/utils/catalog.ts | 273 +++++++++++++++++- .../nuxt-cli/test/unit/utils/catalog.spec.ts | 62 ++++ pnpm-lock.yaml | 10 - 4 files changed, 324 insertions(+), 22 deletions(-) diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 44b4dca97..15caad850 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -74,7 +74,6 @@ "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.1", - "pnpm-workspace-yaml": "^1.7.0", "rc9": "^3.0.1", "scule": "^1.3.0", "source-map-js": "^1.2.1", diff --git a/packages/nuxt-cli/src/utils/catalog.ts b/packages/nuxt-cli/src/utils/catalog.ts index e4461f341..01c179045 100644 --- a/packages/nuxt-cli/src/utils/catalog.ts +++ b/packages/nuxt-cli/src/utils/catalog.ts @@ -2,8 +2,8 @@ import type { PackageJson } from 'pkg-types' import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { parseYAML } from 'confbox/yaml' import { dirname, join, resolve } from 'pathe' -import { parsePnpmWorkspaceYaml } from 'pnpm-workspace-yaml' const CATALOG_SPECIFIER_RE = /^catalog:(.*)$/ @@ -76,16 +76,20 @@ export function readCatalogConfig(cwd: string): CatalogConfig | undefined { return config } +interface WorkspaceYaml { + catalog?: Record + catalogs?: Record> +} + function parseCatalogConfig(filePath: string): CatalogConfig | undefined { - let workspace: ReturnType + let json: WorkspaceYaml try { - workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) + json = parseYAML(readFileSync(filePath, 'utf-8')) || {} } catch { return undefined } - const json = workspace.toJSON() const catalogs: CatalogConfig['catalogs'] = { ...json.catalogs } if (json.catalog) { catalogs[DEFAULT_CATALOG] = json.catalog @@ -133,20 +137,267 @@ export function updateCatalogEntries(cwd: string, updates: CatalogEntryUpdate[]) return 'failed' } + let source: string try { - const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) - for (const { catalog, pkg, specifier } of updates) { - workspace.setPackage(catalog, pkg, specifier) + source = readFileSync(filePath, 'utf-8') + // Reject anything we cannot understand before rewriting a line of it. + parseYAML(source) + } + catch { + return 'failed' + } + + const lines = source.split('\n') + let changed = false + + for (const { catalog, pkg, specifier } of updates) { + const result = setCatalogEntry(lines, catalog, pkg, specifier) + if (result === 'failed') { + return 'failed' } - if (!workspace.hasChanged()) { + changed ||= result === 'updated' + } + + if (!changed) { + return 'unchanged' + } + + try { + writeFileSync(filePath, lines.join('\n'), 'utf-8') + } + catch { + return 'failed' + } + + configCache.delete(filePath) + return 'updated' +} + +/** + * A `key:` line, split into its indentation, raw (possibly quoted) key and the + * inline value that follows. Blank lines, comments and sequence items do not match. + */ +const KEY_LINE_RE = /^(\s*)(?:(?["'])(?(?:\\.|(?!\k).)*)\k\s*|(?[^#\s"'][^:]*)):(?.*)$/ + +interface KeyLine { + indent: number + key: string + /** The key exactly as written, including quotes. */ + raw: string + value: string +} + +function parseKeyLine(line: string): KeyLine | undefined { + const match = line.match(KEY_LINE_RE) + if (!match?.groups) { + return undefined + } + const { quote, quoted, plain, rest } = match.groups + if (rest !== '' && !rest!.startsWith(' ')) { + return undefined + } + const key = quote ? quoted! : plain!.trimEnd() + return { + indent: match[1]!.length, + key: quote ? unescapeYAMLString(key, quote) : key, + raw: quote ? `${quote}${quoted}${quote}` : key, + value: rest!.trimStart(), + } +} + +function unescapeYAMLString(value: string, quote: string): string { + return quote === '\'' ? value.replaceAll('\'\'', '\'') : JSON.parse(`"${value}"`) +} + +function isBlankOrComment(line: string): boolean { + const trimmed = line.trim() + return trimmed === '' || trimmed.startsWith('#') +} + +/** Split an inline value into the scalar itself and any trailing comment. */ +function splitTrailingComment(value: string): [scalar: string, comment: string] { + if (value.startsWith('#')) { + return ['', value] + } + const match = value.match(/\s+#.*$/) + if (!match) { + return [value, ''] + } + return [value.slice(0, match.index), match[0]!] +} + +/** + * Rewrite (or add) `pkg: specifier` inside `catalog`, editing only the line that + * declares it so surrounding comments, anchors and formatting survive. + */ +function setCatalogEntry(lines: string[], catalog: string, pkg: string, specifier: string): UpdateCatalogEntriesResult { + const block = findBlock(lines, catalogPath(catalog)) + if (block === 'failed') { + return 'failed' + } + + if (!block) { + return insertCatalogBlock(lines, catalog, pkg, specifier) + } + + const { start, end, indent } = block + let entryIndent: number | undefined + let lastEntry = start + + for (let index = start + 1; index < end; index++) { + const line = lines[index]! + if (isBlankOrComment(line)) { + continue + } + const entry = parseKeyLine(line) + if (!entry || entry.indent <= indent) { + return 'failed' + } + entryIndent ??= entry.indent + if (entry.indent !== entryIndent) { + continue + } + lastEntry = index + if (entry.key !== pkg) { + continue + } + + const [scalar, comment] = splitTrailingComment(entry.value) + const anchor = scalar.match(/^&\S+\s+/)?.[0] ?? '' + const current = scalar.slice(anchor.length) + if (current.startsWith('*')) { + // Rewriting an alias would silently retarget every other use of the anchor. + return 'failed' + } + if (current === specifier || current === `"${specifier}"` || current === `'${specifier}'`) { return 'unchanged' } - writeFileSync(filePath, workspace.toString(), 'utf-8') - configCache.delete(filePath) + lines[index] = `${' '.repeat(entry.indent)}${entry.raw}: ${anchor}${quoteYAMLScalar(specifier)}${comment}` return 'updated' } - catch { + + lines.splice(lastEntry + 1, 0, `${' '.repeat(entryIndent ?? indent + 2)}${quoteYAMLKey(pkg)}: ${quoteYAMLScalar(specifier)}`) + return 'updated' +} + +interface CatalogBlock { + /** Index of the `catalog:` / `:` line itself. */ + start: number + /** Index one past the last line belonging to the block. */ + end: number + indent: number +} + +function catalogPath(catalog: string): string[] { + return catalog === DEFAULT_CATALOG ? ['catalog'] : ['catalogs', catalog] +} + +function findBlock(lines: string[], path: string[]): CatalogBlock | undefined | 'failed' { + let start = -1 + let indent = 0 + let end = lines.length + + for (const [depth, segment] of path.entries()) { + const from = start + 1 + const parentIndent = indent + let childIndent: number | undefined + start = -1 + for (let index = from; index < end; index++) { + const line = lines[index]! + if (isBlankOrComment(line)) { + continue + } + const key = parseKeyLine(line) + if (!key) { + continue + } + if (depth === 0) { + if (key.indent !== 0) { + continue + } + } + else { + if (key.indent <= parentIndent) { + break + } + childIndent ??= key.indent + if (key.indent !== childIndent) { + continue + } + } + if (key.key !== segment) { + continue + } + if (splitTrailingComment(key.value)[0] !== '') { + // A flow mapping, alias or anchored value is not safe to edit by line. + return 'failed' + } + start = index + indent = key.indent + end = findBlockEnd(lines, index, indent) + break + } + if (start === -1) { + return undefined + } + } + + return { start, end, indent } +} + +function findBlockEnd(lines: string[], start: number, indent: number): number { + let end = start + 1 + for (let index = start + 1; index < lines.length; index++) { + const line = lines[index]! + if (isBlankOrComment(line)) { + continue + } + if (line.search(/\S/) <= indent) { + break + } + end = index + 1 + } + return end +} + +function insertCatalogBlock(lines: string[], catalog: string, pkg: string, specifier: string): UpdateCatalogEntriesResult { + const entry = `${quoteYAMLKey(pkg)}: ${quoteYAMLScalar(specifier)}` + + if (catalog === DEFAULT_CATALOG) { + return appendLines(lines, ['catalog:', ` ${entry}`]) + } + + const catalogs = findBlock(lines, ['catalogs']) + if (catalogs === 'failed') { return 'failed' } + if (!catalogs) { + return appendLines(lines, ['catalogs:', ` ${catalog}:`, ` ${entry}`]) + } + + lines.splice(catalogs.end, 0, `${' '.repeat(catalogs.indent + 2)}${catalog}:`, `${' '.repeat(catalogs.indent + 4)}${entry}`) + return 'updated' +} + +function appendLines(lines: string[], toAppend: string[]): UpdateCatalogEntriesResult { + while (lines.length > 0 && lines.at(-1)!.trim() === '') { + lines.pop() + } + lines.push(...toAppend, '') + return 'updated' +} + +const PLAIN_KEY_RE = /^[\w.][\w.\-/]*$/ + +function quoteYAMLKey(key: string): string { + return PLAIN_KEY_RE.test(key) ? key : JSON.stringify(key) +} + +function quoteYAMLScalar(value: string): string { + const needsQuotes = value === '' + || /^[-?:,[\]{}#&*!|>'"%@`]/.test(value) + || /:\s|\s#|[\n\t]/.test(value) + || value.trim() !== value + return needsQuotes ? JSON.stringify(value) : value } diff --git a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts index a60f08aa3..ed97c31c1 100644 --- a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts @@ -166,6 +166,68 @@ describe('updateCatalogEntries', () => { expect(readCatalogConfig(tempDir)?.catalogs.default).toEqual({ nuxt: '^4.2.0' }) }) + it('should keep an anchor when updating the entry that defines it', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'catalogs:\n prod:\n nuxt: &nuxt ^4.1.0\n legacy:\n nuxt: *nuxt\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'prod', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('catalogs:\n prod:\n nuxt: &nuxt ^4.2.0\n legacy:\n nuxt: *nuxt\n') + }) + + it('should fail rather than retarget an anchor through one of its aliases', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalogs:\n prod:\n nuxt: &nuxt ^4.1.0\n legacy:\n nuxt: *nuxt\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'legacy', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('failed') + }) + + it('should add a missing entry to an existing catalog', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'packages:\n - packages/*\ncatalog:\n vue: ^3.6.0 # pinned\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: '@nuxt/kit', specifier: '^4.2.0' }])).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('packages:\n - packages/*\ncatalog:\n vue: ^3.6.0 # pinned\n "@nuxt/kit": ^4.2.0\n') + }) + + it('should create the catalog blocks when the workspace has none', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'packages:\n - packages/*\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated') + expect(updateCatalogEntries(tempDir, [{ catalog: 'prod', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('packages:\n - packages/*\ncatalog:\n nuxt: ^4.2.0\ncatalogs:\n prod:\n nuxt: ^4.2.0\n') + }) + + it('should add a named catalog alongside existing ones', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'catalogs:\n dev:\n typescript: ^5.9.0\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'prod', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('catalogs:\n dev:\n typescript: ^5.9.0\n prod:\n nuxt: ^4.2.0\n') + }) + + it('should replace a quoted specifier and keep its trailing comment', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'catalog:\n nuxt: "^4.1.0" # keep me\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.1.0' }])).toBe('unchanged') + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: 'npm:nuxt-nightly@latest' }])).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('catalog:\n nuxt: npm:nuxt-nightly@latest # keep me\n') + }) + + it('should ignore a nested key that shares the catalog name', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'overrides:\n catalog:\n nuxt: ^1.0.0\ncatalog:\n nuxt: ^4.1.0\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('overrides:\n catalog:\n nuxt: ^1.0.0\ncatalog:\n nuxt: ^4.2.0\n') + }) + + it('should fail on a flow mapping it cannot edit line by line', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog: { nuxt: ^4.1.0 }\n') + + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('failed') + }) + it('should fail when there is no workspace file', () => { expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('failed') }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7421df7f4..73b76da8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,9 +223,6 @@ importers: pkg-types: specifier: ^2.3.1 version: 2.3.1 - pnpm-workspace-yaml: - specifier: ^1.7.0 - version: 1.7.0 rc9: specifier: ^3.0.1 version: 3.0.1 @@ -5103,9 +5100,6 @@ packages: pnpm-workspace-yaml@1.6.1: resolution: {integrity: sha512-yTeZntGWi8m9WNuhoVsP0DpFc4sC1U0+rr/qR6Zi9n2g3sxXY+JfccjXjjruNz96tM8I09yaJUA86doRnNLkbg==} - pnpm-workspace-yaml@1.7.0: - resolution: {integrity: sha512-cgjaozHkjWL4H8oKZydEWE4mg31XydK3/1cLKjHvwnFAXutp45yAlMKljpOdZEq4TtLuzYAMvy9j05wRm3aoPw==} - postcss-calc@10.1.1: resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} engines: {node: ^18.12 || ^20.9 || >=22.0} @@ -12486,10 +12480,6 @@ snapshots: dependencies: yaml: 2.9.0 - pnpm-workspace-yaml@1.7.0: - dependencies: - yaml: 2.9.0 - postcss-calc@10.1.1(postcss@8.5.23): dependencies: postcss: 8.5.23