├── .prettierignore ├── dev ├── tsconfig.json ├── index.ts ├── index.html ├── index.css ├── vite.config.ts ├── app.tsx └── lib │ ├── repl-toolkit.d.ts │ └── repl-toolkit.js ├── .gitignore ├── .vscode └── extensions.json ├── .formatignore ├── .editorconfig ├── .claude └── settings.local.json ├── .prettierrc ├── env.d.ts ├── tsconfig.node.json ├── node └── get-module-dependencies-demo.ts ├── src ├── types.ts ├── index.ts ├── extension-presets │ ├── html-extension.ts │ └── js-extension.ts ├── core │ ├── create-file-url.ts │ ├── default-file-url-system.ts │ └── create-file-url-system.ts ├── utils │ ├── transform-babel.ts │ ├── transform-html-worker.ts │ ├── transform-html.ts │ ├── path-utils.ts │ ├── get-module-path-ranges.ts │ ├── monaco │ │ ├── bind-monaco-to-file-system.ts │ │ └── create-monaco-typeloader.ts │ ├── resolve-package-entries.ts │ ├── transform-module-paths.ts │ ├── get-module-dependencies.ts │ ├── download-types.ts │ └── utils.ts └── frame │ └── frame-utils.ts ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.yml │ └── bug-report.yml └── workflows │ ├── format.yml │ ├── deploy.yml │ ├── publish.yml │ └── codeql.yml ├── LICENSE ├── vite.config.ts ├── eslint.config.js ├── vite.config.demo.ts ├── test ├── path-utils.test.ts ├── get-module-path-ranges.test.ts └── get-module-dependencies.test.ts ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | dev/lib/**/* -------------------------------------------------------------------------------- /dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | gitignore 3 | dist 4 | 5 | # tsup 6 | tsup.config.bundled_*.{m,c,}s 7 | .vercel 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /dev/index.ts: -------------------------------------------------------------------------------- 1 | import { render } from 'solid-js/web' 2 | import { App } from './app' 3 | 4 | render(App, document.getElementById('root')!) 5 | -------------------------------------------------------------------------------- /.formatignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | gitignore 3 | dist 4 | dev/lib 5 | 6 | # tsup 7 | 8 | tsup.config.bundled\_\*.{m,c,}s 9 | .vercel 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(grep:*)", 5 | "Bash(pnpm add:*)", 6 | "Bash(pnpm test:*)", 7 | "Bash(npx tsx:*)" 8 | ], 9 | "deny": [] 10 | } 11 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "semi": false, 6 | "singleQuote": true, 7 | "useTabs": false, 8 | "arrowParens": "avoid", 9 | "bracketSpacing": true 10 | } 11 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface ImportMeta { 3 | env: { 4 | NODE_ENV: 'production' | 'development' 5 | PROD: boolean 6 | DEV: boolean 7 | } 8 | } 9 | namespace NodeJS { 10 | interface ProcessEnv { 11 | NODE_ENV: 'production' | 'development' 12 | PROD: boolean 13 | DEV: boolean 14 | } 15 | } 16 | } 17 | 18 | export {} 19 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Repl 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // General 4 | "jsx": "preserve", 5 | "jsxImportSource": "solid-js", 6 | "target": "ESNext", 7 | 8 | // Modules 9 | "allowSyntheticDefaultImports": true, 10 | "allowImportingTsExtensions": true, 11 | "esModuleInterop": true, 12 | "isolatedModules": true, 13 | "module": "ESNext", 14 | "moduleResolution": "bundler", 15 | "noEmit": true, 16 | 17 | // Type Checking & Safety 18 | "strict": true, 19 | "types": ["node"] 20 | }, 21 | "include": ["node"] 22 | } 23 | -------------------------------------------------------------------------------- /node/get-module-dependencies-demo.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import * as ts from 'typescript' 3 | import { getModuleDependencies } from '../src/utils/get-module-dependencies.ts' 4 | import { resolvePath } from '../src/utils/path-utils.ts' 5 | 6 | console.log( 7 | await getModuleDependencies({ 8 | entry: '../dev/App.tsx', 9 | readFile: path => { 10 | const resolvedPath = resolvePath(import.meta.url, path).replace('file://', '') 11 | return fs.readFile(resolvedPath, { 12 | encoding: 'utf-8', 13 | }) 14 | }, 15 | ts, 16 | }), 17 | ) 18 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Accessor } from 'solid-js' 2 | 3 | export type AccessorMaybe = T | Accessor 4 | 5 | export type FileType = 'javascript' | 'css' | 'html' | 'wasm' | 'plain' 6 | export interface Extension { 7 | transform?: Transform 8 | type: FileType 9 | } 10 | export interface FileUrlSystem { 11 | invalidate(path: string): void 12 | get(path: string, config?: { cached?: boolean }): string | undefined 13 | } 14 | export interface TransformConfig { 15 | path: string 16 | source: string 17 | fileUrls: FileUrlSystem 18 | } 19 | export type Transform = (config: TransformConfig) => Accessor | string 20 | -------------------------------------------------------------------------------- /dev/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | box-sizing: border-box; 9 | margin: 0; 10 | padding: 20px; 11 | height: 100vh; 12 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 13 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 14 | } 15 | 16 | #root { 17 | display: grid; 18 | grid-template-rows: auto 1fr 1fr; 19 | gap: 5px; 20 | height: 100%; 21 | overflow: hidden; 22 | } 23 | 24 | .buttons { 25 | display: flex; 26 | align-content: start; 27 | gap: 5px; 28 | } 29 | 30 | iframe { 31 | border: 1px solid black; 32 | width: 100%; 33 | height: 100%; 34 | } 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core/create-file-url-system.ts' 2 | export * from './core/create-file-url.ts' 3 | export * from './core/default-file-url-system.ts' 4 | export * from './extension-presets/html-extension.ts' 5 | export * from './extension-presets/js-extension.ts' 6 | export * from './types.ts' 7 | export * from './utils/download-types.ts' 8 | export * from './utils/get-module-dependencies.ts' 9 | export * from './utils/get-module-path-ranges.ts' 10 | export * from './utils/monaco/create-monaco-typeloader.ts' 11 | export * as PathUtils from './utils/path-utils.ts' 12 | export * from './utils/resolve-package-entries.ts' 13 | export * from './utils/transform-babel.ts' 14 | export * from './utils/transform-html-worker.ts' 15 | export * from './utils/transform-html.ts' 16 | export * from './utils/transform-module-paths.ts' 17 | -------------------------------------------------------------------------------- /src/extension-presets/html-extension.ts: -------------------------------------------------------------------------------- 1 | import type { Accessor } from 'solid-js' 2 | import type { TransformConfig } from '../types.ts' 3 | import { transformHtmlWorker } from '../utils/transform-html-worker.ts' 4 | import { transformHtml } from '../utils/transform-html.ts' 5 | 6 | export interface HTMLExtensionConfig { 7 | transformModule: (config: TransformConfig) => Accessor 8 | } 9 | 10 | export function createHTMLExtension(config: HTMLExtensionConfig) { 11 | return { 12 | type: 'html' as const, 13 | transform(options: TransformConfig) { 14 | return transformHtml({ ...config, ...options }) 15 | }, 16 | } 17 | } 18 | 19 | export function createHTMLExtensionWorker(config: HTMLExtensionConfig) { 20 | return { 21 | type: 'html' as const, 22 | transform(options: TransformConfig) { 23 | return transformHtmlWorker({ ...config, ...options }) 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/core/create-file-url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates an object URL representing a virtual text-based file. 3 | * 4 | * This function wraps a given text `source` into a `Blob` with a MIME type of `text/{type}`, 5 | * then returns an object URL that can be used as a file source (e.g., in an ` 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/transform-babel.ts: -------------------------------------------------------------------------------- 1 | import type * as Babel from '@babel/standalone' 2 | 3 | type Transform = (source: string, path: string) => string 4 | 5 | export interface BabelConfig { 6 | babel?: typeof Babel | Promise 7 | presets?: string[] 8 | plugins?: (string | babel.PluginItem)[] 9 | cdn?: string 10 | } 11 | 12 | function resolveItems({ 13 | cdn, 14 | babel, 15 | items, 16 | type, 17 | }: { 18 | cdn: string 19 | babel: typeof Babel 20 | items?: (string | [string, unknown] | unknown)[] 21 | type: 'plugins' | 'presets' 22 | }): Promise { 23 | if (!items) return Promise.resolve([]) 24 | const availableItems = type === 'plugins' ? babel.availablePlugins : babel.availablePresets 25 | return Promise.all( 26 | items.map(async function resolveItem(item: string | [string, unknown] | unknown) { 27 | let name: string 28 | let options: unknown = undefined 29 | 30 | // Handle both string and array types 31 | if (typeof item === 'string') { 32 | name = item 33 | } else if (Array.isArray(item) && typeof item[0] === 'string') { 34 | ;[name, options] = item 35 | } else { 36 | return item // Return non-string, non-array items directly 37 | } 38 | 39 | // Check for item in available items or import from CDN 40 | if (name in availableItems) { 41 | return options !== undefined ? [availableItems[name], options] : availableItems[name] 42 | } else { 43 | const module = await import(/* @vite-ignore */ `${cdn}/${name}`).then( 44 | module => module.default, 45 | ) 46 | return options !== undefined ? [module, options] : module 47 | } 48 | }), 49 | ) 50 | } 51 | 52 | export async function babelTransform(config: BabelConfig): Promise { 53 | const cdn = config.cdn || 'https://esm.sh' 54 | 55 | const babel = await (config.babel || 56 | (import(/* @vite-ignore */ `${cdn}/@babel/standalone`) as Promise)) 57 | 58 | const [presets, plugins] = await Promise.all([ 59 | resolveItems({ cdn, babel, items: config.presets, type: 'presets' }), 60 | resolveItems({ cdn, babel, items: config.plugins, type: 'plugins' }), 61 | ]) 62 | 63 | return (source, path) => { 64 | const result = babel.transform(source, { 65 | presets, 66 | plugins, 67 | }).code 68 | 69 | if (!result) throw `Babel transform failed for file ${path} with source: \n\n ${source}` 70 | 71 | return result 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/transform-html-worker.ts: -------------------------------------------------------------------------------- 1 | import serialize from 'dom-serializer' 2 | import type { Element } from 'domhandler' 3 | import { findAll, getAttributeValue, hasAttrib } from 'domutils' 4 | import { parseDocument } from 'htmlparser2' 5 | import type { Accessor } from 'solid-js' 6 | import type { TransformConfig } from '../types.ts' 7 | import * as PathUtils from './path-utils.ts' 8 | 9 | export interface TransformHtmlWorkerConfig extends TransformConfig { 10 | transformModule(config: TransformConfig): Accessor 11 | } 12 | 13 | export function transformHtmlWorker({ 14 | path, 15 | source, 16 | fileUrls, 17 | transformModule, 18 | }: TransformHtmlWorkerConfig) { 19 | const doc = parseDocument(source) 20 | 21 | const updatelinkHref = createUpdateFn(doc, 'link', link => { 22 | const href = getAttributeValue(link, 'href')! 23 | if (!href || PathUtils.isUrl(href)) return 24 | return () => { 25 | const url = fileUrls.get(PathUtils.resolvePath(path, href)) 26 | if (url) link.attribs.href = url 27 | } 28 | }) 29 | const updateScriptSrc = createUpdateFn(doc, 'script', script => { 30 | if (hasAttrib(script, 'src')) { 31 | const src = getAttributeValue(script, 'src') 32 | if (!src || PathUtils.isUrl(src)) return 33 | return () => { 34 | const url = fileUrls.get(PathUtils.resolvePath(path, src)) 35 | if (url) script.attribs.src = url 36 | } 37 | } 38 | }) 39 | const updateModuleTextContent = createUpdateFn(doc, 'script', script => { 40 | const childNode = script.children[0] 41 | const source = getAttributeValue(script, 'textContent') 42 | if (getAttributeValue(script, 'type') !== 'module' || !childNode || !source) return 43 | const transformed = transformModule({ path, fileUrls, source }) 44 | return () => { 45 | if ('data' in childNode) { 46 | childNode.data = transformed() 47 | } 48 | } 49 | }) 50 | 51 | return () => { 52 | updatelinkHref() 53 | updateScriptSrc() 54 | updateModuleTextContent() 55 | return serialize(doc, { decodeEntities: true }) 56 | } 57 | } 58 | 59 | function createUpdateFn( 60 | doc: ReturnType, 61 | selector: string, 62 | callback: (element: Element) => (() => void) | undefined, 63 | ) { 64 | const updateFns = findAll( 65 | elem => !!(elem.tagName && elem.tagName.toLowerCase() === selector.toLowerCase()), 66 | doc.children, 67 | ).map(element => callback(element)) 68 | return () => updateFns.forEach(updateFn => updateFn?.()) 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/transform-html.ts: -------------------------------------------------------------------------------- 1 | import type { Accessor } from 'solid-js' 2 | import type { TransformConfig } from '../types.ts' 3 | import * as PathUtils from './path-utils.ts' 4 | 5 | // Create a new DOMParser and XMLSerializer-instance 6 | const domParser = typeof DOMParser !== 'undefined' ? new DOMParser() : undefined 7 | const xmlSerializer = typeof XMLSerializer !== 'undefined' ? new XMLSerializer() : undefined 8 | 9 | export interface TransformHtmlConfig extends TransformConfig { 10 | transformModule(config: TransformConfig): Accessor 11 | } 12 | 13 | export function transformHtml({ path, source, fileUrls, transformModule }: TransformHtmlConfig) { 14 | if (!domParser || !xmlSerializer) { 15 | throw `\`parseHtml\` can only be used in environments where DOMParser and XMLSerializer are available. Please use \`parseHtmlWorker\` for a worker-friendly alternative.` 16 | } 17 | const doc = domParser.parseFromString(source, 'text/html') 18 | 19 | const updatelinkHref = createUpdateFn(doc, 'link[href]', link => { 20 | const href = link.getAttribute('href')! 21 | if (PathUtils.isUrl(href)) return 22 | return () => { 23 | const url = fileUrls.get(PathUtils.resolvePath(path, href)) 24 | if (url) link.setAttribute('href', url) 25 | } 26 | }) 27 | const updateScriptSrc = createUpdateFn(doc, 'script[src]', script => { 28 | const src = script.getAttribute('src')! 29 | if (PathUtils.isUrl(src)) return 30 | return () => { 31 | const url = fileUrls.get(PathUtils.resolvePath(path, src)) 32 | if (url) script.setAttribute('src', url) 33 | } 34 | }) 35 | const updateModuleTextContent = createUpdateFn( 36 | doc, 37 | 'script[type="module"]', 38 | script => { 39 | const source = script.textContent 40 | if (script.type !== 'module' || !source) return 41 | const transformed = transformModule({ path, fileUrls, source }) 42 | return () => (script.textContent = transformed()) 43 | }, 44 | ) 45 | 46 | return () => { 47 | updatelinkHref() 48 | updateScriptSrc() 49 | updateModuleTextContent() 50 | return xmlSerializer.serializeToString(doc) 51 | } 52 | } 53 | 54 | function createUpdateFn( 55 | doc: Document, 56 | selector: string, 57 | callback: (element: T) => (() => void) | undefined, 58 | ) { 59 | const updateFns = Array.from(doc.querySelectorAll(selector)).map(element => callback(element)) 60 | return () => updateFns.forEach(updateFn => updateFn?.()) 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/path-utils.ts: -------------------------------------------------------------------------------- 1 | export function getExtension(path: string) { 2 | return path.split('/').slice(-1)[0]?.split('.')[1] || '' 3 | } 4 | 5 | export function getName(path: string) { 6 | const parts = path.split('/') 7 | return parts[parts.length - 1] || '' 8 | } 9 | 10 | export function getParentPath(path: string) { 11 | const parts = path.split('/') 12 | return parts.slice(0, -1).join('/') 13 | } 14 | 15 | export function normalizePath(path: string) { 16 | return path.replace(/^\/+/, '') 17 | } 18 | 19 | export function resolvePath(currentPath: string, relativePath: string) { 20 | // Handle URLs 21 | if (isUrl(currentPath)) { 22 | try { 23 | const absoluteUrl = new URL(relativePath, currentPath) 24 | return normalizePath(absoluteUrl.href) 25 | } catch { 26 | // Fallback for blob URLs or other special cases 27 | const base = currentPath.substring(0, currentPath.lastIndexOf('/')) 28 | return `${base}/${relativePath.replace(/^\.\//, '')}` 29 | } 30 | } 31 | 32 | // Split paths and remove filename from current path 33 | const baseParts = currentPath.split('/').slice(0, -1) 34 | const relativeParts = relativePath.split('/').filter(part => part !== '' && part !== '.') 35 | 36 | // Determine if we're working with a relative base path 37 | const isRelativeBase = currentPath.startsWith('../') || currentPath.startsWith('./') 38 | 39 | // Build the result path 40 | const resultParts = [...baseParts] 41 | 42 | for (const part of relativeParts) { 43 | if (part === '..') { 44 | if (resultParts.length > 0 && resultParts[resultParts.length - 1] !== '..') { 45 | // Pop directory if not at a relative boundary 46 | if (resultParts[resultParts.length - 1] === '.') { 47 | resultParts[resultParts.length - 1] = '..' 48 | } else { 49 | resultParts.pop() 50 | } 51 | } else if (isRelativeBase) { 52 | // Only go up further if we started with a relative path 53 | resultParts.push('..') 54 | } 55 | // Otherwise we're at the root of a non-relative path, so skip 56 | } else { 57 | resultParts.push(part) 58 | } 59 | } 60 | 61 | // Handle empty result 62 | if (resultParts.length === 0) { 63 | return relativeParts[relativeParts.length - 1] || '' 64 | } 65 | 66 | // Join and clean up 67 | let result = resultParts.join('/') 68 | 69 | // Remove leading './' only for non-relative results 70 | if (result.startsWith('./') && !result.includes('../')) { 71 | result = result.substring(2) 72 | } 73 | 74 | return result 75 | } 76 | 77 | export function isUrl(path: string) { 78 | return path.startsWith('blob:') || path.startsWith('http:') || path.startsWith('https:') 79 | } 80 | -------------------------------------------------------------------------------- /test/path-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { resolvePath } from '../src/utils/path-utils' 3 | 4 | describe('resolvePath', () => { 5 | it('should handle basic relative paths with ../', () => { 6 | expect(resolvePath('../../example.ts', './other.ts')).toBe('../../other.ts') 7 | expect(resolvePath('../module.ts', './sibling.ts')).toBe('../sibling.ts') 8 | expect(resolvePath('./local.ts', '../parent.ts')).toBe('../parent.ts') 9 | }) 10 | 11 | it('should handle paths without relative prefixes', () => { 12 | expect(resolvePath('src/index.ts', '../utils.ts')).toBe('utils.ts') 13 | expect(resolvePath('src/deep/file.ts', './sibling.ts')).toBe('src/deep/sibling.ts') 14 | }) 15 | 16 | it('should handle absolute paths', () => { 17 | expect(resolvePath('/absolute/path.ts', './same-dir.ts')).toBe('/absolute/same-dir.ts') 18 | expect(resolvePath('/root/dir/file.ts', '../other.ts')).toBe('/root/other.ts') 19 | }) 20 | 21 | it('should handle complex relative navigation', () => { 22 | expect(resolvePath('../../../deep/file.ts', '../../up.ts')).toBe('../../../../up.ts') 23 | expect(resolvePath('./a/b/c.ts', '../../../d.ts')).toBe('../d.ts') 24 | expect(resolvePath('../a/b.ts', '../../c/d.ts')).toBe('../../c/d.ts') 25 | expect(resolvePath('../../a/b/c.ts', '../d/e.ts')).toBe('../../a/d/e.ts') 26 | }) 27 | 28 | it('should handle ./ prefixed paths correctly', () => { 29 | expect(resolvePath('./file.ts', './other.ts')).toBe('other.ts') 30 | expect(resolvePath('./dir/file.ts', './sibling.ts')).toBe('dir/sibling.ts') 31 | expect(resolvePath('./a/b.ts', '../../c.ts')).toBe('../c.ts') 32 | }) 33 | 34 | it('should handle URLs', () => { 35 | expect(resolvePath('https://example.com/path/file.js', './other.js')).toBe( 36 | 'https://example.com/path/other.js', 37 | ) 38 | expect(resolvePath('https://example.com/path/file.js', '../parent.js')).toBe( 39 | 'https://example.com/parent.js', 40 | ) 41 | expect(resolvePath('https://example.com/dir/file.js', '../../root.js')).toBe( 42 | 'https://example.com/root.js', 43 | ) 44 | }) 45 | 46 | it('should handle blob URLs', () => { 47 | expect(resolvePath('blob:http://example.com/123/file.js', './other.js')).toBe( 48 | 'blob:http://example.com/123/other.js', 49 | ) 50 | }) 51 | 52 | it('should handle edge cases with multiple ../', () => { 53 | expect(resolvePath('a/b/c/d.ts', '../../../../e.ts')).toBe('e.ts') 54 | expect(resolvePath('../a/b/c.ts', '../../../d/e.ts')).toBe('../../d/e.ts') 55 | expect(resolvePath('../../file.ts', '../../../other.ts')).toBe('../../../../../other.ts') 56 | }) 57 | 58 | it('should handle paths with trailing slashes', () => { 59 | expect(resolvePath('src/dir/', './file.ts')).toBe('src/dir/file.ts') 60 | expect(resolvePath('src/dir/', '../file.ts')).toBe('src/file.ts') 61 | }) 62 | 63 | it('should handle empty relative paths', () => { 64 | expect(resolvePath('src/file.ts', '.')).toBe('src') 65 | expect(resolvePath('src/file.ts', './')).toBe('src') 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/utils/get-module-path-ranges.ts: -------------------------------------------------------------------------------- 1 | import type * as TS from 'typescript' 2 | 3 | export interface ModulePathRange { 4 | start: number 5 | end: number 6 | path: string 7 | isImport: boolean 8 | isDynamic?: boolean 9 | } 10 | 11 | export interface GetModulePathRangesOptions { 12 | ts: typeof TS 13 | source: string 14 | include?: { 15 | imports?: boolean 16 | exports?: boolean 17 | dynamicImports?: boolean 18 | } 19 | } 20 | 21 | /** 22 | * Extracts all module path ranges from TypeScript source code. 23 | * Finds import and export declarations and returns their module specifier positions. 24 | * 25 | * @param options - Configuration for extracting ranges 26 | * @param options.ts - TypeScript compiler API instance 27 | * @param options.source - Source code to analyze 28 | * @param options.include - Options to include/exclude specific types of imports/exports 29 | * @returns Array of ranges containing start/end positions, module paths, and whether it's an import 30 | * 31 | * @example 32 | * ```typescript 33 | * const ranges = getModulePathRanges({ 34 | * ts: typescript, 35 | * source: 'import { foo } from "./bar.js"', 36 | * include: { imports: true, exports: false, dynamicImports: true } 37 | * }); 38 | * // Returns: [{ start: 21, end: 30, path: "./bar.js", isImport: true }] 39 | * ``` 40 | */ 41 | export function getModulePathRanges({ 42 | ts, 43 | source, 44 | include = { imports: true, exports: true, dynamicImports: true }, 45 | }: GetModulePathRangesOptions) { 46 | const sourceFile = ts.createSourceFile('', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) 47 | 48 | const ranges: Array = [] 49 | 50 | function collect(node: TS.Node) { 51 | // Handle import and export declarations 52 | if ( 53 | (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && 54 | node.moduleSpecifier && 55 | ts.isStringLiteral(node.moduleSpecifier) 56 | ) { 57 | const isImport = ts.isImportDeclaration(node) 58 | 59 | // Check if this type should be included 60 | if ((isImport && !include.imports) || (!isImport && !include.exports)) { 61 | ts.forEachChild(node, collect) 62 | return 63 | } 64 | 65 | const text = node.moduleSpecifier.text 66 | const start = node.moduleSpecifier.getStart(sourceFile) + 1 // skip quote 67 | const end = node.moduleSpecifier.getEnd() - 1 // skip quote 68 | 69 | ranges.push({ start, end, path: text, isImport, isDynamic: false }) 70 | } 71 | 72 | // Handle dynamic imports: import('...') 73 | if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { 74 | if (!include.dynamicImports) { 75 | ts.forEachChild(node, collect) 76 | return 77 | } 78 | 79 | const arg = node.arguments[0] 80 | if (arg && ts.isStringLiteral(arg)) { 81 | const text = arg.text 82 | const start = arg.getStart(sourceFile) + 1 // skip quote 83 | const end = arg.getEnd() - 1 // skip quote 84 | 85 | ranges.push({ start, end, path: text, isImport: true, isDynamic: true }) 86 | } 87 | } 88 | 89 | ts.forEachChild(node, collect) 90 | } 91 | 92 | collect(sourceFile) 93 | 94 | return ranges 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/monaco/bind-monaco-to-file-system.ts: -------------------------------------------------------------------------------- 1 | import type * as Monaco from 'monaco-editor' 2 | import { createEffect, mapArray, mergeProps, onCleanup } from 'solid-js' 3 | import * as PathUtils from '../path-utils.ts' 4 | import { createAsync } from '../utils.ts' 5 | 6 | export function bindMonacoToFileSystem(props: { 7 | editor: Monaco.editor.IStandaloneCodeEditor 8 | readFile: (path: string) => string 9 | writeFile: (path: string, value: string) => void 10 | getPaths: () => string[] 11 | languages?: Record 12 | monaco: typeof Monaco 13 | path: string 14 | tsconfig?: Monaco.languages.typescript.CompilerOptions 15 | types?: Record 16 | }) { 17 | const languages = mergeProps( 18 | { 19 | tsx: 'typescript', 20 | ts: 'typescript', 21 | }, 22 | () => props.languages, 23 | ) 24 | const worker = createAsync(() => props.monaco.languages.typescript.getTypeScriptWorker()) 25 | 26 | function getType(path: string) { 27 | const extension = PathUtils.getExtension(path) 28 | if (extension && extension in languages) { 29 | return languages[extension]! 30 | } 31 | return 'raw' 32 | } 33 | 34 | createEffect(() => { 35 | props.editor.onDidChangeModelContent(() => { 36 | props.writeFile(props.path, props.editor.getModel()!.getValue()) 37 | }) 38 | }) 39 | 40 | createEffect( 41 | mapArray(props.getPaths, path => { 42 | createEffect(() => { 43 | const type = getType(path) 44 | if (type === 'dir') return 45 | const uri = props.monaco.Uri.parse(`file:///${path}`) 46 | const model = 47 | props.monaco.editor.getModel(uri) || props.monaco.editor.createModel('', type, uri) 48 | createEffect(() => { 49 | const value = props.readFile(path) || '' 50 | if (value !== model.getValue()) { 51 | model.setValue(props.readFile(path) || '') 52 | } 53 | }) 54 | onCleanup(() => model.dispose()) 55 | }) 56 | }), 57 | ) 58 | 59 | createEffect(() => { 60 | const uri = props.monaco.Uri.parse(`file:///${props.path}`) 61 | const type = getType(props.path) 62 | const model = 63 | props.monaco.editor.getModel(uri) || props.monaco.editor.createModel('', type, uri) 64 | props.editor.setModel(model) 65 | 66 | const client = createAsync(() => worker()?.(model.uri)) 67 | const diagnosis = createAsync( 68 | () => client()?.getSemanticDiagnostics(props.readFile(props.path)), 69 | ) 70 | createEffect(() => { 71 | console.info('diagnosis', diagnosis()) 72 | }) 73 | }) 74 | 75 | createEffect(() => { 76 | if (props.tsconfig) { 77 | props.monaco.languages.typescript.typescriptDefaults.setCompilerOptions(props.tsconfig) 78 | props.monaco.languages.typescript.javascriptDefaults.setCompilerOptions(props.tsconfig) 79 | } 80 | }) 81 | 82 | createEffect( 83 | mapArray( 84 | () => Object.keys(props.types ?? {}), 85 | name => { 86 | createEffect(() => { 87 | const declaration = props.types?.[name] 88 | if (!declaration) return 89 | const path = `file:///${name}` 90 | props.monaco.languages.typescript.typescriptDefaults.addExtraLib(declaration, path) 91 | props.monaco.languages.typescript.javascriptDefaults.addExtraLib(declaration, path) 92 | }) 93 | }, 94 | ), 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bigmistqke/repl", 3 | "version": "0.2.7", 4 | "description": "Virtual FileSystem and utilties for composing REPLs.", 5 | "license": "MIT", 6 | "author": "bigmistqke", 7 | "contributors": [], 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/bigmistqke/repl.git" 11 | }, 12 | "homepage": "https://github.com/bigmistqke/repl#readme", 13 | "bugs": { 14 | "url": "https://github.com/bigmistqke/repl/issues" 15 | }, 16 | "files": [ 17 | "dist" 18 | ], 19 | "private": false, 20 | "sideEffects": false, 21 | "type": "module", 22 | "main": "./dist/index.js", 23 | "module": "./dist/index.js", 24 | "types": "./dist/index.d.ts", 25 | "exports": { 26 | ".": { 27 | "import": { 28 | "types": "./dist/index.d.ts", 29 | "default": "./dist/index.js" 30 | } 31 | } 32 | }, 33 | "scripts": { 34 | "build": "vite build .", 35 | "build:dev": "vite build dev", 36 | "dev": "vite serve dev", 37 | "format": "prettier -w \"src/**/*.{js,ts,json,css,tsx,jsx}\" \"dev/**/*.{js,ts,json,css,tsx,jsx}\"", 38 | "lint": "concurrently pnpm:lint:*", 39 | "lint:circular": "dpdm src/index.ts", 40 | "lint:code": "eslint --max-warnings 0 \"src/**/*.ts\"", 41 | "lint:types": "tsc --noEmit", 42 | "optimize": "vite optimize", 43 | "prepublishOnly": "pnpm lint && pnpm build", 44 | "serve:demo": "vite preview demo", 45 | "test": "concurrently pnpm:test:*", 46 | "test:client": "vitest --run", 47 | "test:ssr": "pnpm run test:client --mode ssr", 48 | "update-deps": "pnpm up -Li" 49 | }, 50 | "dependencies": { 51 | "@bigmistqke/solid-whenever": "^0.1.1", 52 | "@solid-primitives/set": "^0.7.1" 53 | }, 54 | "peerDependencies": { 55 | "@babel/standalone": "^7.26.6", 56 | "dom-serializer": "^2.0.0", 57 | "domutils": "^3.2.2", 58 | "htmlparser2": "^10.0.0", 59 | "monaco-editor": "^0.52.2", 60 | "solid-js": "^1.6.0", 61 | "typescript": "^5.1.6" 62 | }, 63 | "devDependencies": { 64 | "@bigmistqke/solid-fs-components": "0.1.0-beta", 65 | "@bigmistqke/solid-grid-split": "^0.0.2", 66 | "@bigmistqke/vite-plugin-raw-directory": "^0.0.2", 67 | "@bigmistqke/vite-plugin-worker-proxy": "^0.0.12", 68 | "@bigmistqke/worker-proxy": "^0.0.12", 69 | "@esbuild-plugins/node-globals-polyfill": "^0.2.3", 70 | "@monaco-editor/loader": "^1.4.0", 71 | "@solid-primitives/filesystem": "^1.3.1", 72 | "@solid-primitives/map": "^0.7.1", 73 | "@solidjs/router": "^0.15.2", 74 | "@types/babel__standalone": "^7.1.9", 75 | "@types/node": "^24.6.1", 76 | "@typescript-eslint/eslint-plugin": "^8.44.1", 77 | "@typescript-eslint/parser": "^8.44.1", 78 | "concurrently": "^8.2.0", 79 | "domhandler": "^5.0.3", 80 | "dpdm": "^3.14.0", 81 | "eslint": "^9.36.0", 82 | "jsdom": "^27.0.0", 83 | "prettier": "3.0.0", 84 | "typescript": "^5.1.6", 85 | "vite": "^4.4.6", 86 | "vite-plugin-dts-bundle-generator": "^2.0.4", 87 | "vite-plugin-solid": "^2.7.0", 88 | "vite-plugin-wasm": "^3.3.0", 89 | "vite-tsconfig-paths": "^4.3.2", 90 | "vitest": "^0.33.0", 91 | "wabt": "^1.0.36" 92 | }, 93 | "keywords": [ 94 | "solid", 95 | "repl", 96 | "playground", 97 | "typescript", 98 | "monaco" 99 | ], 100 | "engines": { 101 | "node": ">=18", 102 | "pnpm": ">=8.6.0" 103 | }, 104 | "packageManager": "pnpm@9.1.1" 105 | } 106 | -------------------------------------------------------------------------------- /src/utils/monaco/create-monaco-typeloader.ts: -------------------------------------------------------------------------------- 1 | import type * as Monaco from 'monaco-editor' 2 | import { createEffect, createSignal } from 'solid-js' 3 | import { createStore } from 'solid-js/store' 4 | import type TS from 'typescript' 5 | import { downloadTypesfromPackageName } from '../download-types.ts' 6 | import { mapObject } from '../utils.ts' 7 | 8 | /** 9 | * Creates a manager for downloading and tracking TypeScript declaration files (`.d.ts`) 10 | * for use with Monaco Editor, based on a given `tsconfig`. 11 | * 12 | * This utility supports dynamically adding downloaded types, aliasing module names, 13 | * and reactively watching for changes to the types and tsconfig. 14 | * 15 | * @param tsconfig - The initial TypeScript compiler options to extend and reactively update. 16 | * 17 | * @returns An API with the following methods: 18 | * 19 | * ### Configuration and State 20 | * - `tsconfig()` — Returns the current `tsconfig` including added paths for downloaded modules. 21 | * - `types()` — Returns the current record of downloaded declaration file contents. 22 | * 23 | * ### Modifications 24 | * - `addDeclaration(path, source, alias?)` — Adds a new declaration manually, optionally aliasing it to a module name. 25 | * - `downloadModule(name)` — Downloads types for the specified npm package and adds them automatically. 26 | * 27 | * ### Watchers 28 | * - `watchTsconfig(cb)` — Registers a callback to be called whenever the `tsconfig` changes. 29 | * - `watchTypes(cb)` — Registers a callback to be called whenever the types change. 30 | * 31 | * @example 32 | * const downloader = createMonacoTypeDownloader({ 33 | * target: monaco.languages.typescript.ScriptTarget.ESNext, 34 | * moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, 35 | * }); 36 | * 37 | * downloader.downloadModule('lodash'); 38 | * downloader.watchTypes(types => { 39 | * console.log('Updated types:', types); 40 | * }); 41 | */ 42 | export function createMonacoTypeDownloader({ 43 | ts, 44 | tsconfig, 45 | }: { 46 | ts: typeof TS 47 | tsconfig: Monaco.languages.typescript.CompilerOptions 48 | }) { 49 | const [types, setTypes] = createStore>({}) 50 | const [aliases, setAliases] = createSignal>>({}) 51 | 52 | function addAlias(alias: string, path: string) { 53 | setAliases(paths => { 54 | paths[alias] = [`file:///${path}`] 55 | return { ...paths } 56 | }) 57 | } 58 | 59 | const methods = { 60 | tsconfig() { 61 | return { 62 | ...tsconfig, 63 | paths: { 64 | ...mapObject(tsconfig.paths || {}, value => value.map(path => `file:///${path}`)), 65 | ...aliases(), 66 | }, 67 | } 68 | }, 69 | types() { 70 | return types 71 | }, 72 | addDeclaration(path: string, source: string, alias?: string) { 73 | setTypes(path, source) 74 | if (alias) { 75 | addAlias(alias, path) 76 | } 77 | }, 78 | async downloadModule(name: string) { 79 | if (!(name in aliases())) { 80 | const { types, path } = await downloadTypesfromPackageName({ name, ts }) 81 | setTypes(types) 82 | addAlias(name, path) 83 | } 84 | }, 85 | // Watchers 86 | watchTsconfig(cb: (tsconfig: Monaco.languages.typescript.CompilerOptions) => void) { 87 | createEffect(() => cb(methods.tsconfig())) 88 | }, 89 | watchTypes(cb: (types: Record) => void) { 90 | createEffect(() => cb({ ...types })) 91 | }, 92 | } 93 | 94 | return methods 95 | } 96 | -------------------------------------------------------------------------------- /src/frame/frame-utils.ts: -------------------------------------------------------------------------------- 1 | import { defer } from '../utils/utils.ts' 2 | 3 | /** 4 | * Waits for an iframe to have a `contentWindow` available and returns it. 5 | * 6 | * If the iframe's `contentWindow` is not immediately available, waits for the iframe to load. 7 | * 8 | * @param iframe - The target iframe element. 9 | * @returns A promise that resolves with the iframe's `contentWindow`. 10 | */ 11 | export async function getContentWindow(iframe: HTMLIFrameElement) { 12 | const contentWindow = iframe.contentWindow 13 | 14 | if (!contentWindow) { 15 | await waitForLoad(iframe) 16 | } 17 | 18 | return iframe.contentWindow! 19 | } 20 | 21 | /** 22 | * Returns a promise that resolves when the given element emits a `load` event. 23 | * 24 | * @param element - The element to wait for (e.g., an `