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 {