diff --git a/README.md b/README.md
index b529b203..1aa5ff5a 100644
--- a/README.md
+++ b/README.md
@@ -132,3 +132,73 @@ const store = useStore(
```
+
+
+Configuration options for resource links. (replace CDN resources)
+
+```ts
+export type ResourceLinkConfigs = {
+ /** URL for ES Module Shims. */
+ esModuleShims?: string
+ /** Function that generates the Vue compiler URL based on the version. */
+ vueCompilerUrl?: (version: string) => string
+ /** Function that generates the TypeScript library URL based on the version. */
+ typescriptLib?: (version: string) => string
+
+ /** [monaco] Function that generates a URL to fetch the latest version of a package. */
+ pkgLatestVersionUrl?: (pkgName: string) => string
+ /** [monaco] Function that generates a URL to browse a package directory. */
+ pkgDirUrl?: (pkgName: string, pkgVersion: string, pkgPath: string) => string
+ /** [monaco] Function that generates a URL to fetch the content of a file from a package. */
+ pkgFileTextUrl?: (
+ pkgName: string,
+ pkgVersion: string | undefined,
+ pkgPath: string,
+ ) => string
+}
+```
+
+**unpkg**
+
+```ts
+const store = useStore({
+ resourceLinks: ref({
+ esModuleShims:
+ 'https://unpkg.com/es-module-shims@1.5.18/dist/es-module-shims.wasm.js',
+ vueCompilerUrl: (version) =>
+ `https://unpkg.com/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js`,
+ typescriptLib: (version) =>
+ `https://unpkg.com/typescript@${version}/lib/typescript.js`,
+ pkgLatestVersionUrl: (pkgName) =>
+ `https://unpkg.com/${pkgName}@latest/package.json`,
+ pkgDirUrl: (pkgName, pkgVersion, pkgPath) =>
+ `https://unpkg.com/${pkgName}@${pkgVersion}/${pkgPath}/?meta`,
+ pkgFileTextUrl: (pkgName, pkgVersion, pkgPath) =>
+ `https://unpkg.com/${pkgName}@${pkgVersion || 'latest'}/${pkgPath}`,
+ }),
+})
+```
+
+**npmmirror**
+
+```ts
+const store = useStore({
+ resourceLinks: ref({
+ esModuleShims:
+ 'https://registry.npmmirror.com/es-module-shims/1.5.18/files/dist/es-module-shims.wasm.js',
+ vueCompilerUrl: (version) =>
+ `https://registry.npmmirror.com/@vue/compiler-sfc/${version}/files/dist/compiler-sfc.esm-browser.js`,
+ typescriptLib: (version) =>
+ `https://registry.npmmirror.com/typescript/${version}/files/lib/typescript.js`,
+
+ pkgLatestVersionUrl: (pkgName) =>
+ `https://registry.npmmirror.com/${pkgName}/latest/files/package.json`,
+ pkgDirUrl: (pkgName, pkgVersion, pkgPath) =>
+ `https://registry.npmmirror.com/${pkgName}/${pkgVersion}/files/${pkgPath}/?meta`,
+ pkgFileTextUrl: (pkgName, pkgVersion, pkgPath) =>
+ `https://registry.npmmirror.com/${pkgName}/${pkgVersion || 'latest'}/files/${pkgPath}`,
+ }),
+})
+```
+
+
diff --git a/package.json b/package.json
index ab1f9233..6585453c 100644
--- a/package.json
+++ b/package.json
@@ -86,7 +86,7 @@
"@types/hash-sum": "^1.0.2",
"@types/node": "^22.13.4",
"@vitejs/plugin-vue": "^5.2.1",
- "@volar/jsdelivr": "~2.4.11",
+ "@volar/language-service": "~2.4.11",
"@volar/monaco": "~2.4.11",
"@vue/babel-plugin-jsx": "^1.2.5",
"@vue/language-service": "~2.2.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8d25d3a8..31b4fa52 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,7 +38,7 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@6.1.0(@types/node@22.13.4)(jiti@2.4.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3))
- '@volar/jsdelivr':
+ '@volar/language-service':
specifier: ~2.4.11
version: 2.4.11
'@volar/monaco':
@@ -763,9 +763,6 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
- '@volar/jsdelivr@2.4.11':
- resolution: {integrity: sha512-HbOg2cccKcpgxK/bS9+vFzfb2o4RUqLSCJjcGuYWRRuUhyw0gqkpnrSRMovgG/rUpkqVXDCnfIeoHdl04z0BZg==}
-
'@volar/language-core@2.4.11':
resolution: {integrity: sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==}
@@ -3185,8 +3182,6 @@ snapshots:
vite: 6.1.0(@types/node@22.13.4)(jiti@2.4.2)(yaml@2.7.0)
vue: 3.5.13(typescript@5.7.3)
- '@volar/jsdelivr@2.4.11': {}
-
'@volar/language-core@2.4.11':
dependencies:
'@volar/source-map': 2.4.11
diff --git a/src/monaco/env.ts b/src/monaco/env.ts
index 8074bba9..2fc02630 100644
--- a/src/monaco/env.ts
+++ b/src/monaco/env.ts
@@ -122,6 +122,10 @@ export interface WorkerMessage {
event: 'init'
tsVersion: string
tsLocale?: string
+ pkgDirUrl?: string
+ pkgFileTextUrl?: string
+ pkgLatestVersionUrl?: string
+ typescriptLib?: string
}
export function loadMonacoEnv(store: Store) {
@@ -135,11 +139,27 @@ export function loadMonacoEnv(store: Store) {
resolve()
}
})
- worker.postMessage({
+
+ const {
+ pkgDirUrl,
+ pkgFileTextUrl,
+ pkgLatestVersionUrl,
+ typescriptLib,
+ } = store.resourceLinks || {}
+
+ const message: WorkerMessage = {
event: 'init',
tsVersion: store.typescriptVersion,
tsLocale: store.locale,
- } satisfies WorkerMessage)
+ pkgDirUrl: pkgDirUrl ? String(pkgDirUrl) : undefined,
+ pkgFileTextUrl: pkgFileTextUrl ? String(pkgFileTextUrl) : undefined,
+ pkgLatestVersionUrl: pkgLatestVersionUrl
+ ? String(pkgLatestVersionUrl)
+ : undefined,
+ typescriptLib: typescriptLib ? String(typescriptLib) : undefined,
+ }
+
+ worker.postMessage(message)
})
await init
return worker
diff --git a/src/monaco/resource.ts b/src/monaco/resource.ts
new file mode 100644
index 00000000..da94d290
--- /dev/null
+++ b/src/monaco/resource.ts
@@ -0,0 +1,359 @@
+/**
+ * base on @volar/jsdelivr
+ * MIT License https://github.com/volarjs/volar.js/blob/master/packages/jsdelivr/LICENSE
+ */
+import type { FileStat, FileSystem, FileType } from '@volar/language-service'
+import type { URI } from 'vscode-uri'
+
+const textCache = new Map>()
+const jsonCache = new Map>()
+
+export type CreateNpmFileSystemOptions = {
+ getPackageLatestVersionUrl?: (pkgName: string) => string
+ getPackageDirectoryUrl?: (
+ pkgName: string,
+ pkgVersion: string,
+ pkgPath: string,
+ ) => string
+ getPackageFileTextUrl?: (
+ pkgName: string,
+ pkgVersion: string | undefined,
+ pkgPath: string,
+ ) => string
+}
+
+const defaultUnpkgOptions: Required = {
+ getPackageLatestVersionUrl: (pkgName) =>
+ `https://unpkg.com/${pkgName}@latest/package.json`,
+ getPackageDirectoryUrl: (pkgName, pkgVersion, pkgPath) =>
+ `https://unpkg.com/${pkgName}@${pkgVersion}/${pkgPath}/?meta`,
+ getPackageFileTextUrl: (pkgName, pkgVersion, pkgPath) =>
+ `https://unpkg.com/${pkgName}@${pkgVersion || 'latest'}/${pkgPath}`,
+}
+
+export function createNpmFileSystem(
+ getCdnPath = (uri: URI): string | undefined => {
+ if (uri.path === '/node_modules') {
+ return ''
+ } else if (uri.path.startsWith('/node_modules/')) {
+ return uri.path.slice('/node_modules/'.length)
+ }
+ },
+ getPackageVersion?: (pkgName: string) => string | undefined,
+ onFetch?: (path: string, content: string) => void,
+ options?: CreateNpmFileSystemOptions,
+): FileSystem {
+ const {
+ getPackageDirectoryUrl = defaultUnpkgOptions.getPackageDirectoryUrl,
+ getPackageFileTextUrl = defaultUnpkgOptions.getPackageFileTextUrl,
+ getPackageLatestVersionUrl = defaultUnpkgOptions.getPackageLatestVersionUrl,
+ } = options || {}
+
+ const fetchResults = new Map>()
+ const statCache = new Map()
+ const dirCache = new Map()
+
+ return {
+ async stat(uri) {
+ const path = getCdnPath(uri)
+ if (path === undefined) {
+ return
+ }
+ if (path === '') {
+ return {
+ type: 2 satisfies FileType.Directory,
+ size: -1,
+ ctime: -1,
+ mtime: -1,
+ }
+ }
+ return await _stat(path)
+ },
+ async readFile(uri) {
+ const path = getCdnPath(uri)
+ if (path === undefined) {
+ return
+ }
+ return await _readFile(path)
+ },
+ readDirectory(uri) {
+ const path = getCdnPath(uri)
+ if (path === undefined) {
+ return []
+ }
+ return _readDirectory(path)
+ },
+ }
+
+ async function _stat(path: string) {
+ if (statCache.has(path)) {
+ return {
+ ...statCache.get(path),
+ ctime: -1,
+ mtime: -1,
+ size: -1,
+ } as FileStat
+ }
+
+ const [modName, pkgName, , pkgFilePath] = resolvePackageName(path)
+ if (!pkgName) {
+ if (modName.startsWith('@')) {
+ return {
+ type: 2 satisfies FileType.Directory,
+ ctime: -1,
+ mtime: -1,
+ size: -1,
+ }
+ } else {
+ return
+ }
+ }
+ if (!(await isValidPackageName(pkgName))) {
+ return
+ }
+
+ if (!pkgFilePath || pkgFilePath === '/') {
+ const result = {
+ type: 2 as FileType.Directory,
+ }
+ statCache.set(path, result)
+ return { ...result, ctime: -1, mtime: -1, size: -1 }
+ }
+
+ try {
+ const parentDir = path.substring(0, path.lastIndexOf('/'))
+ const fileName = path.substring(path.lastIndexOf('/') + 1)
+
+ const dirContent = await _readDirectory(parentDir)
+ const fileEntry = dirContent.find(([name]) => name === fileName)
+
+ if (fileEntry) {
+ const result = {
+ type: fileEntry[1] as FileType,
+ }
+ statCache.set(path, result)
+ return { ...result, ctime: -1, mtime: -1, size: -1 }
+ }
+
+ return
+ } catch {
+ return
+ }
+ }
+
+ async function _readDirectory(path: string): Promise<[string, FileType][]> {
+ if (dirCache.has(path)) {
+ return dirCache.get(path)!
+ }
+
+ const [, pkgName, pkgVersion, pkgPath] = resolvePackageName(path)
+
+ if (!pkgName || !(await isValidPackageName(pkgName))) {
+ return []
+ }
+
+ const resolvedVersion = pkgVersion || 'latest'
+
+ let actualVersion = resolvedVersion
+ if (resolvedVersion === 'latest') {
+ try {
+ const data = await fetchJson<{ version: string }>(
+ getPackageLatestVersionUrl(pkgName),
+ )
+ if (data?.version) {
+ actualVersion = data.version
+ }
+ } catch {
+ // ignore
+ }
+ }
+
+ const endpoint = getPackageDirectoryUrl(pkgName, actualVersion, pkgPath)
+ try {
+ const data = await fetchJson<{
+ files: {
+ path: string
+ type: 'file' | 'directory'
+ size?: number
+ }[]
+ }>(endpoint)
+
+ if (!data?.files) {
+ return []
+ }
+
+ const result: [string, FileType][] = data.files.map((file) => {
+ const type =
+ file.type === 'directory'
+ ? (2 as FileType.Directory)
+ : (1 as FileType.File)
+
+ const fullPath = file.path
+ statCache.set(fullPath, { type })
+
+ return [_getNameFromPath(file.path), type]
+ })
+
+ dirCache.set(path, result)
+ return result
+ } catch {
+ return []
+ }
+ }
+
+ function _getNameFromPath(path: string): string {
+ if (!path) return ''
+
+ const trimmedPath = path.endsWith('/') ? path.slice(0, -1) : path
+
+ const lastSlashIndex = trimmedPath.lastIndexOf('/')
+
+ if (
+ lastSlashIndex === -1 ||
+ (lastSlashIndex === 0 && trimmedPath.length === 1)
+ ) {
+ return trimmedPath
+ }
+
+ return trimmedPath.slice(lastSlashIndex + 1)
+ }
+
+ async function _readFile(path: string): Promise {
+ const [_modName, pkgName, _version, pkgFilePath] = resolvePackageName(path)
+ if (!pkgName || !pkgFilePath || !(await isValidPackageName(pkgName))) {
+ return
+ }
+
+ if (!fetchResults.has(path)) {
+ fetchResults.set(
+ path,
+ (async () => {
+ if ((await _stat(path))?.type !== (1 satisfies FileType.File)) {
+ return
+ }
+ const text = await fetchText(
+ getPackageFileTextUrl(pkgName, _version, pkgFilePath),
+ )
+ if (text !== undefined) {
+ onFetch?.(path, text)
+ }
+ return text
+ })(),
+ )
+ }
+
+ return await fetchResults.get(path)!
+ }
+
+ async function isValidPackageName(pkgName: string) {
+ // ignore @aaa/node_modules
+ if (pkgName.endsWith('/node_modules')) {
+ return false
+ }
+ // hard code to skip known invalid package
+ if (
+ pkgName.endsWith('.d.ts') ||
+ pkgName.startsWith('@typescript/') ||
+ pkgName.startsWith('@types/typescript__')
+ ) {
+ return false
+ }
+ // don't check @types if original package already having types
+ if (pkgName.startsWith('@types/')) {
+ let originalPkgName = pkgName.slice('@types/'.length)
+ if (originalPkgName.indexOf('__') >= 0) {
+ originalPkgName = '@' + originalPkgName.replace('__', '/')
+ }
+ const packageJson = await _readFile(`${originalPkgName}/package.json`)
+ if (!packageJson) {
+ return false
+ }
+ const packageJsonObj = JSON.parse(packageJson)
+ if (packageJsonObj.types || packageJsonObj.typings) {
+ return false
+ }
+ const indexDts = await _stat(`${originalPkgName}/index.d.ts`)
+ if (indexDts?.type === (1 satisfies FileType.File)) {
+ return false
+ }
+ }
+ return true
+ }
+
+ /**
+ * @example
+ * "a/b/c" -> ["a", "a", undefined, "b/c"]
+ * "@a" -> ["@a", undefined, undefined, ""]
+ * "@a/b/c" -> ["@a/b", "@a/b", undefined, "c"]
+ * "@a/b@1.2.3/c" -> ["@a/b@1.2.3", "@a/b", "1.2.3", "c"]
+ */
+ function resolvePackageName(
+ input: string,
+ ): [
+ modName: string,
+ pkgName: string | undefined,
+ version: string | undefined,
+ path: string,
+ ] {
+ const parts = input.split('/')
+ let modName = parts[0]
+ let path: string
+ if (modName.startsWith('@')) {
+ if (!parts[1]) {
+ return [modName, undefined, undefined, '']
+ }
+ modName += '/' + parts[1]
+ path = parts.slice(2).join('/')
+ } else {
+ path = parts.slice(1).join('/')
+ }
+ let pkgName = modName
+ let version: string | undefined
+ if (modName.lastIndexOf('@') >= 1) {
+ pkgName = modName.substring(0, modName.lastIndexOf('@'))
+ version = modName.substring(modName.lastIndexOf('@') + 1)
+ }
+ if (!version && getPackageVersion) {
+ version = getPackageVersion?.(pkgName)
+ }
+ return [modName, pkgName, version, path]
+ }
+}
+
+async function fetchText(url: string) {
+ if (!textCache.has(url)) {
+ textCache.set(
+ url,
+ (async () => {
+ try {
+ const res = await fetch(url)
+ if (res.status === 200) {
+ return await res.text()
+ }
+ } catch {
+ // ignore
+ }
+ })(),
+ )
+ }
+ return await textCache.get(url)!
+}
+
+async function fetchJson(url: string) {
+ if (!jsonCache.has(url)) {
+ jsonCache.set(
+ url,
+ (async () => {
+ try {
+ const res = await fetch(url)
+ if (res.status === 200) {
+ return await res.json()
+ }
+ } catch {
+ // ignore
+ }
+ })(),
+ )
+ }
+ return (await jsonCache.get(url)!) as T
+}
diff --git a/src/monaco/vue.worker.ts b/src/monaco/vue.worker.ts
index 0940cadb..61867043 100644
--- a/src/monaco/vue.worker.ts
+++ b/src/monaco/vue.worker.ts
@@ -5,7 +5,6 @@ import {
type LanguageServiceEnvironment,
createTypeScriptWorkerLanguageService,
} from '@volar/monaco/worker'
-import { createNpmFileSystem } from '@volar/jsdelivr'
import {
type VueCompilerOptions,
getFullLanguageServicePlugins,
@@ -14,6 +13,7 @@ import {
} from '@vue/language-service'
import type { WorkerHost, WorkerMessage } from './env'
import { URI } from 'vscode-uri'
+import { createNpmFileSystem } from './resource'
export interface CreateData {
tsconfig: {
@@ -23,13 +23,35 @@ export interface CreateData {
dependencies: Record
}
+function createFunc(func?: string) {
+ if (func && typeof func === 'string') {
+ return Function(`return ${func}`)()
+ }
+ return undefined
+}
+
let ts: typeof import('typescript')
let locale: string | undefined
+let resourceLinks: Record<
+ keyof Pick<
+ WorkerMessage,
+ 'pkgDirUrl' | 'pkgFileTextUrl' | 'pkgLatestVersionUrl'
+ >,
+ ((...args: any[]) => string) | undefined
+>
self.onmessage = async (msg: MessageEvent) => {
if (msg.data?.event === 'init') {
locale = msg.data.tsLocale
- ts = await importTsFromCdn(msg.data.tsVersion)
+ ts = await importTsFromCdn(
+ msg.data.tsVersion,
+ createFunc(msg.data.typescriptLib),
+ )
+ resourceLinks = {
+ pkgDirUrl: createFunc(msg.data.pkgDirUrl),
+ pkgFileTextUrl: createFunc(msg.data.pkgFileTextUrl),
+ pkgLatestVersionUrl: createFunc(msg.data.pkgLatestVersionUrl),
+ }
self.postMessage('inited')
return
}
@@ -61,6 +83,11 @@ self.onmessage = async (msg: MessageEvent) => {
content,
)
},
+ {
+ getPackageDirectoryUrl: resourceLinks.pkgDirUrl,
+ getPackageFileTextUrl: resourceLinks.pkgFileTextUrl,
+ getPackageLatestVersionUrl: resourceLinks.pkgLatestVersionUrl,
+ },
),
}
@@ -98,10 +125,15 @@ self.onmessage = async (msg: MessageEvent) => {
)
}
-async function importTsFromCdn(tsVersion: string) {
+async function importTsFromCdn(
+ tsVersion: string,
+ getTsCdn?: (version?: string) => string,
+) {
const _module = globalThis.module
;(globalThis as any).module = { exports: {} }
- const tsUrl = `https://cdn.jsdelivr.net/npm/typescript@${tsVersion}/lib/typescript.js`
+ const tsUrl =
+ getTsCdn?.(tsVersion) ||
+ `https://cdn.jsdelivr.net/npm/typescript@${tsVersion}/lib/typescript.js`
await import(/* @vite-ignore */ tsUrl)
const ts = globalThis.module.exports
globalThis.module = _module
diff --git a/src/output/Sandbox.vue b/src/output/Sandbox.vue
index eba9c688..43977c99 100644
--- a/src/output/Sandbox.vue
+++ b/src/output/Sandbox.vue
@@ -128,6 +128,11 @@ function createSandbox() {
//,
previewOptions.value?.placeholderHTML || '',
)
+ .replace(
+ //,
+ store.value.resourceLinks?.esModuleShims ||
+ 'https://cdn.jsdelivr.net/npm/es-module-shims@1.5.18/dist/es-module-shims.wasm.js',
+ )
sandbox.srcdoc = sandboxSrc
containerRef.value?.appendChild(sandbox)
diff --git a/src/output/srcdoc.html b/src/output/srcdoc.html
index 189c19ca..407bbd91 100644
--- a/src/output/srcdoc.html
+++ b/src/output/srcdoc.html
@@ -6,8 +6,9 @@
color-scheme: dark;
}
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
- Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ font-family:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
+ Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
@@ -361,10 +362,7 @@
-
+
diff --git a/src/store.ts b/src/store.ts
index 2508b679..550cee99 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -48,6 +48,7 @@ export function useStore(
typescriptVersion = ref('latest'),
dependencyVersion = ref(Object.create(null)),
reloadLanguageTools = ref(),
+ resourceLinks = undefined,
}: Partial = {},
serializedState?: string,
): ReplStore {
@@ -92,7 +93,9 @@ export function useStore(
vueVersion,
async (version) => {
if (version) {
- const compilerUrl = `https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js`
+ const compilerUrl =
+ resourceLinks?.value?.vueCompilerUrl?.(version) ||
+ `https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@${version}/dist/compiler-sfc.esm-browser.js`
loading.value = true
compiler.value = await import(/* @vite-ignore */ compilerUrl).finally(
() => (loading.value = false),
@@ -389,6 +392,8 @@ export function useStore(
deserialize,
getFiles,
setFiles,
+
+ resourceLinks,
})
return store
}
@@ -414,6 +419,20 @@ export interface SFCOptions {
template?: Partial
}
+export type ResourceLinkConfigs = {
+ esModuleShims?: string
+ vueCompilerUrl?: (version: string) => string
+ typescriptLib?: (version: string) => string
+ // for monaco
+ pkgLatestVersionUrl?: (pkgName: string) => string
+ pkgDirUrl?: (pkgName: string, pkgVersion: string, pkgPath: string) => string
+ pkgFileTextUrl?: (
+ pkgName: string,
+ pkgVersion: string | undefined,
+ pkgPath: string,
+ ) => string
+}
+
export type StoreState = ToRefs<{
files: Record
activeFilename: string
@@ -440,6 +459,9 @@ export type StoreState = ToRefs<{
/** \{ dependencyName: version \} */
dependencyVersion: Record
reloadLanguageTools?: (() => void) | undefined
+
+ /** Custom online resources */
+ resourceLinks?: ResourceLinkConfigs
}>
export interface ReplStore extends UnwrapRef {
@@ -463,6 +485,8 @@ export interface ReplStore extends UnwrapRef {
deserialize(serializedState: string, checkBuiltinImportMap?: boolean): void
getFiles(): Record
setFiles(newFiles: Record, mainFile?: string): Promise
+ /** Custom online resources */
+ resourceLinks?: ResourceLinkConfigs
}
export type Store = Pick<
@@ -487,6 +511,7 @@ export type Store = Pick<
| 'renameFile'
| 'getImportMap'
| 'getTsConfig'
+ | 'resourceLinks'
>
export class File {