Skip to content

Commit 9317d96

Browse files
authored
feat: bump to pnpm 11, migrate shiki-magic-move to @shikijs/magic-move, update deps (#2615)
1 parent 6baeb85 commit 9317d96

14 files changed

Lines changed: 2658 additions & 1588 deletions

File tree

.github/workflows/smoke.yml

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ jobs:
7979
- name: Setup PNPM
8080
uses: pnpm/action-setup@v4
8181

82+
# pnpm v11 stores globally-installed binaries in `$PNPM_HOME/bin` instead
83+
# of directly in `$PNPM_HOME`; without this, `pnpm i -g` aborts with
84+
# "configured global bin directory is not in PATH".
85+
- name: Add pnpm global bin to PATH
86+
run: echo "$PNPM_HOME/bin" >> "$GITHUB_PATH"
87+
shell: bash
88+
8289
- name: Install
8390
run: nci
8491

@@ -129,19 +136,26 @@ jobs:
129136
start: pnpm --dir ../temp/slidev-project dev --port 3041
130137
spec: cypress/e2e/examples/smoke.spec.ts
131138

139+
# Global-mode tests are exercised against npm only. pnpm v11 isolates
140+
# each top-level `pnpm add -g <pkg>` into its own install group whose
141+
# CAS only carries that package's direct deps; optional peers like
142+
# `@vue/compiler-sfc` (required at runtime by `unplugin-icons` →
143+
# `local-pkg`) never end up co-located with their importer and the
144+
# build fails. Slidev's resolver was taught to discover sibling
145+
# install groups (so theme resolution works), but the optional-peer
146+
# gap needs an upstream fix in pnpm or in the plugins that import
147+
# `@vue/compiler-sfc` lazily. Re-enable once that lands.
132148
- name: Install globally
133-
run: |
134-
${{ matrix.pm }} i -g /tmp/slidev-pkgs/cli.tgz playwright-chromium
135-
${{ matrix.pm }} i -g @slidev/theme-seriph
136-
if: ${{ matrix.pm != 'yarn' }}
149+
run: npm i -g /tmp/slidev-pkgs/cli.tgz playwright-chromium @slidev/theme-seriph
150+
if: ${{ matrix.pm == 'npm' }}
137151

138152
- name: Create slide file
139153
run: pnpm --package=cpy-cli dlx cpy ./packages/slidev/template.md ../temp/ --flat
140-
if: ${{ matrix.pm != 'yarn' }}
154+
if: ${{ matrix.pm == 'npm' }}
141155

142156
- name: Test build command in global mode
143157
run: slidev build template.md
144-
if: ${{ matrix.pm != 'yarn' }}
158+
if: ${{ matrix.pm == 'npm' }}
145159
working-directory: ../temp
146160

147161
# Commented out because it's not working

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"type": "module",
33
"version": "52.15.2",
44
"private": true,
5-
"packageManager": "pnpm@10.33.4",
5+
"packageManager": "pnpm@11.5.1",
66
"engines": {
77
"node": ">=20.12.0"
88
},

packages/client/builtin/ShikiMagicMove.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<script setup lang="ts">
2-
import type { KeyedTokensInfo } from 'shiki-magic-move/types'
2+
import type { KeyedTokensInfo } from '@shikijs/magic-move/types'
33
import type { PropType } from 'vue'
44
import { sleep } from '@antfu/utils'
5+
import { ShikiMagicMovePrecompiled } from '@shikijs/magic-move/vue'
56
import { useClipboard } from '@vueuse/core'
67
import lz from 'lz-string'
7-
import { ShikiMagicMovePrecompiled } from 'shiki-magic-move/vue'
88
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
99
import { useNav } from '../composables/useNav'
1010
import { CLICKS_MAX } from '../constants'

packages/client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@iconify-json/ph": "catalog:icons",
3535
"@iconify-json/svg-spinners": "catalog:icons",
3636
"@shikijs/engine-javascript": "catalog:frontend",
37+
"@shikijs/magic-move": "catalog:frontend",
3738
"@shikijs/monaco": "catalog:monaco",
3839
"@shikijs/vitepress-twoslash": "catalog:prod",
3940
"@slidev/parser": "workspace:*",
@@ -60,7 +61,6 @@
6061
"pptxgenjs": "catalog:prod",
6162
"recordrtc": "catalog:frontend",
6263
"shiki": "catalog:frontend",
63-
"shiki-magic-move": "catalog:frontend",
6464
"typescript": "catalog:dev",
6565
"unocss": "catalog:prod",
6666
"vue": "catalog:frontend",

packages/client/styles/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import 'uno:preflights.css'
44
import 'uno:typography.css'
55
import 'uno:shortcuts.css'
66

7+
import '@shikijs/magic-move/style.css'
78
import '@shikijs/vitepress-twoslash/style.css'
8-
import 'shiki-magic-move/style.css'
99
import 'markdown-it-github-alerts/styles/github-colors-light.css'
1010
import 'markdown-it-github-alerts/styles/github-colors-dark-media.css'
1111
import 'markdown-it-github-alerts/styles/github-base.css'
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
shamefullyHoist: true
2+
3+
# `playwright-chromium` is needed for `slidev export` (PDF/PNG/PPTX) and has a
4+
# postinstall script that downloads the browser. pnpm v11 errors on unapproved
5+
# build scripts by default (`strictDepBuilds: true`), so allowlist it up-front.
6+
allowBuilds:
7+
playwright-chromium: true

packages/slidev/node/resolver.test.ts

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import { resolve, sep } from 'node:path'
12
import { beforeEach, describe, expect, it, vi } from 'vitest'
2-
import { createResolver, getRoots } from './resolver'
3+
import {
4+
computeInvocationSearchPaths,
5+
createResolver,
6+
findInvocationNodeModulesPath,
7+
getRoots,
8+
} from './resolver'
39

410
const vitefuMocks = vi.hoisted(() => {
511
const FAKE_ROOT = '/user/project'
@@ -21,6 +27,58 @@ const vitefuMocks = vi.hoisted(() => {
2127

2228
vi.mock('vitefu', () => vitefuMocks)
2329

30+
// Virtual filesystem the `node:fs` mock reads from. Tests populate it with
31+
// the paths they care about before exercising the resolver helpers.
32+
type FsNodeKind = 'dir' | 'file' | 'symlink'
33+
interface FsNode { kind: FsNodeKind, target?: string }
34+
const fsMocks = vi.hoisted(() => {
35+
const tree = new Map<string, { kind: 'dir' | 'file' | 'symlink', target?: string }>()
36+
const ENOENT = (path: string) => {
37+
const err = Object.assign(new Error(`ENOENT: no such file or directory '${path}'`), { code: 'ENOENT' })
38+
return err
39+
}
40+
return {
41+
tree,
42+
existsSync: vi.fn((path: string) => tree.has(path)),
43+
lstatSync: vi.fn((path: string) => {
44+
const node = tree.get(path)
45+
if (!node)
46+
throw ENOENT(path)
47+
return {
48+
isSymbolicLink: () => node.kind === 'symlink',
49+
isDirectory: () => node.kind === 'dir',
50+
isFile: () => node.kind === 'file',
51+
}
52+
}),
53+
readlinkSync: vi.fn((path: string) => {
54+
const node = tree.get(path)
55+
if (!node || node.kind !== 'symlink')
56+
throw Object.assign(new Error(`EINVAL: readlink '${path}'`), { code: 'EINVAL' })
57+
return node.target!
58+
}),
59+
readdirSync: vi.fn((path: string) => {
60+
const node = tree.get(path)
61+
if (!node || node.kind !== 'dir')
62+
throw Object.assign(new Error(`ENOTDIR: scandir '${path}'`), { code: 'ENOTDIR' })
63+
const prefix = path.endsWith(sep) ? path : `${path}${sep}`
64+
const children = new Set<string>()
65+
for (const key of tree.keys()) {
66+
if (key === path)
67+
continue
68+
if (key.startsWith(prefix)) {
69+
const rest = key.slice(prefix.length)
70+
const first = rest.split(sep)[0]
71+
if (first)
72+
children.add(first)
73+
}
74+
}
75+
return Array.from(children).sort()
76+
}),
77+
}
78+
})
79+
80+
vi.mock('node:fs', () => fsMocks)
81+
2482
describe('createResolver', () => {
2583
beforeEach(async () => {
2684
await getRoots('/user/project')
@@ -137,3 +195,173 @@ describe('createResolver', () => {
137195
)
138196
})
139197
})
198+
199+
function setNode(path: string, node: FsNode) {
200+
fsMocks.tree.set(resolve(path), node)
201+
}
202+
function dir(path: string) {
203+
setNode(path, { kind: 'dir' })
204+
}
205+
function symlink(path: string, target: string) {
206+
// `readlinkSync` returns the target verbatim — relative or absolute — so we
207+
// store it as-given rather than resolving eagerly, so relative-target paths
208+
// exercise the dirname-relative branch in `findInvocationNodeModulesPath`.
209+
setNode(path, { kind: 'symlink', target })
210+
}
211+
212+
describe('findInvocationNodeModulesPath', () => {
213+
beforeEach(() => {
214+
fsMocks.tree.clear()
215+
})
216+
217+
it('returns undefined when argv1 is undefined', () => {
218+
expect(findInvocationNodeModulesPath(undefined)).toBeUndefined()
219+
})
220+
221+
it('returns undefined when argv1 is empty', () => {
222+
expect(findInvocationNodeModulesPath('')).toBeUndefined()
223+
})
224+
225+
it('extracts the trailing node_modules from a pnpm v11 shell-shim dispatched path', () => {
226+
// `{PNPM_HOME}/bin/slidev` shell shim exec's `node <path-to-cli>.mjs`.
227+
const argv1 = resolve('/pnpm-home/v11/abc123/node_modules/@slidev/cli/bin/slidev.mjs')
228+
expect(findInvocationNodeModulesPath(argv1)).toBe(
229+
resolve('/pnpm-home/v11/abc123/node_modules'),
230+
)
231+
})
232+
233+
it('extracts node_modules from an npm/yarn globals path', () => {
234+
const argv1 = resolve('/usr/local/lib/node_modules/@slidev/cli/bin/slidev.mjs')
235+
expect(findInvocationNodeModulesPath(argv1)).toBe(
236+
resolve('/usr/local/lib/node_modules'),
237+
)
238+
})
239+
240+
it('extracts node_modules from a local project install path', () => {
241+
const argv1 = resolve('/my/project/node_modules/@slidev/cli/bin/slidev.mjs')
242+
expect(findInvocationNodeModulesPath(argv1)).toBe(
243+
resolve('/my/project/node_modules'),
244+
)
245+
})
246+
247+
it('uses the last node_modules segment when paths nest', () => {
248+
// Some monorepos have stacked node_modules (e.g. project-level + pnpm's
249+
// own `.pnpm` virtual store). The closest one to the script wins.
250+
const argv1 = resolve('/project/node_modules/.pnpm/@slidev+cli@x/node_modules/@slidev/cli/bin/slidev.mjs')
251+
expect(findInvocationNodeModulesPath(argv1)).toBe(
252+
resolve('/project/node_modules/.pnpm/@slidev+cli@x/node_modules'),
253+
)
254+
})
255+
256+
it('follows a single fs symlink when argv1 itself has no node_modules segment', () => {
257+
// Legacy `.bin` symlink layout: `{prefix}/bin/slidev` -> install group's
258+
// `node_modules/@slidev/cli/bin/slidev.mjs`.
259+
symlink('/usr/local/bin/slidev', '/usr/local/lib/node_modules/@slidev/cli/bin/slidev.mjs')
260+
expect(findInvocationNodeModulesPath('/usr/local/bin/slidev')).toBe(
261+
resolve('/usr/local/lib/node_modules'),
262+
)
263+
})
264+
265+
it('returns undefined when argv1 is neither a symlink nor under a node_modules', () => {
266+
setNode('/some/random/binary', { kind: 'file' })
267+
expect(findInvocationNodeModulesPath('/some/random/binary')).toBeUndefined()
268+
})
269+
270+
it('returns undefined when argv1 does not exist and has no node_modules in its literal path', () => {
271+
expect(findInvocationNodeModulesPath('/does/not/exist')).toBeUndefined()
272+
})
273+
274+
it('handles relative symlink targets', () => {
275+
// pnpm typically writes relative targets like `../../@slidev/cli/...`.
276+
symlink('/usr/local/bin/slidev', '../lib/node_modules/@slidev/cli/bin/slidev.mjs')
277+
expect(findInvocationNodeModulesPath('/usr/local/bin/slidev')).toBe(
278+
resolve('/usr/local/lib/node_modules'),
279+
)
280+
})
281+
282+
it('follows multi-hop symlink chains and stops at the first node_modules', () => {
283+
// `bin/slidev` -> `bin/slidev.real` (still under `bin/`) -> the actual
284+
// `.mjs` file under a `node_modules/`.
285+
symlink('/prefix/bin/slidev', '/prefix/bin/slidev.real')
286+
symlink('/prefix/bin/slidev.real', '/prefix/lib/node_modules/@slidev/cli/bin/slidev.mjs')
287+
expect(findInvocationNodeModulesPath('/prefix/bin/slidev')).toBe(
288+
resolve('/prefix/lib/node_modules'),
289+
)
290+
})
291+
292+
it('gives up after a long symlink chain that never enters a node_modules', () => {
293+
// Build a 20-hop chain of symlinks that all point at each other,
294+
// none of which contain `node_modules`.
295+
for (let i = 0; i < 20; i++)
296+
symlink(`/chain/${i}`, `/chain/${i + 1}`)
297+
setNode(resolve('/chain/20'), { kind: 'file' })
298+
expect(findInvocationNodeModulesPath('/chain/0')).toBeUndefined()
299+
})
300+
})
301+
302+
describe('computeInvocationSearchPaths', () => {
303+
beforeEach(() => {
304+
fsMocks.tree.clear()
305+
})
306+
307+
it('returns an empty array when input is undefined', () => {
308+
expect(computeInvocationSearchPaths(undefined)).toEqual([])
309+
})
310+
311+
it('returns just the input when there are no siblings to enumerate', () => {
312+
// The grandparent of `ownNodeModules` doesn't contain any other install
313+
// groups (e.g. npm globals layout: only `/usr/local/lib/node_modules`).
314+
dir('/usr/local')
315+
dir('/usr/local/lib')
316+
dir('/usr/local/lib/node_modules')
317+
expect(computeInvocationSearchPaths(resolve('/usr/local/lib/node_modules'))).toEqual([
318+
resolve('/usr/local/lib/node_modules'),
319+
])
320+
})
321+
322+
it('enumerates every sibling install group in pnpm v11\'s isolated globals layout', () => {
323+
// pnpm v11 creates one `{hash}/` directory per top-level `pnpm add -g`
324+
// target. Each has its own `node_modules/`.
325+
dir('/pnpm-home/v11')
326+
dir('/pnpm-home/v11/abc')
327+
dir('/pnpm-home/v11/abc/node_modules')
328+
dir('/pnpm-home/v11/def')
329+
dir('/pnpm-home/v11/def/node_modules')
330+
dir('/pnpm-home/v11/ghi')
331+
dir('/pnpm-home/v11/ghi/node_modules')
332+
const result = computeInvocationSearchPaths(resolve('/pnpm-home/v11/abc/node_modules'))
333+
expect(result).toEqual(expect.arrayContaining([
334+
resolve('/pnpm-home/v11/abc/node_modules'),
335+
resolve('/pnpm-home/v11/def/node_modules'),
336+
resolve('/pnpm-home/v11/ghi/node_modules'),
337+
]))
338+
expect(result[0]).toBe(resolve('/pnpm-home/v11/abc/node_modules'))
339+
expect(result).toHaveLength(3)
340+
})
341+
342+
it('skips sibling install groups that have no node_modules subdirectory', () => {
343+
dir('/pnpm-home/v11')
344+
dir('/pnpm-home/v11/abc')
345+
dir('/pnpm-home/v11/abc/node_modules')
346+
dir('/pnpm-home/v11/empty') // no node_modules child
347+
expect(computeInvocationSearchPaths(resolve('/pnpm-home/v11/abc/node_modules'))).toEqual([
348+
resolve('/pnpm-home/v11/abc/node_modules'),
349+
])
350+
})
351+
352+
it('returns just the input when the global root is unreadable', () => {
353+
// Grandparent isn't listed in the virtual fs at all -> readdirSync throws.
354+
expect(computeInvocationSearchPaths(resolve('/missing-root/abc/node_modules'))).toEqual([
355+
resolve('/missing-root/abc/node_modules'),
356+
])
357+
})
358+
359+
it('does not include the input directory as its own sibling', () => {
360+
dir('/pnpm-home/v11')
361+
dir('/pnpm-home/v11/abc')
362+
dir('/pnpm-home/v11/abc/node_modules')
363+
const result = computeInvocationSearchPaths(resolve('/pnpm-home/v11/abc/node_modules'))
364+
// Exactly one occurrence of the input path.
365+
expect(result.filter(p => p === resolve('/pnpm-home/v11/abc/node_modules'))).toHaveLength(1)
366+
})
367+
})

0 commit comments

Comments
 (0)