0.1.3 • Published 2 months ago

@bigmistqke/repl v0.1.3

Weekly downloads
-
License
MIT
Repository
github
Last release
2 months ago

@bigmistqke/repl

@bigmistqke/repl provides unstyled building blocks to create TypeScript playgrounds utilizing the Monaco Editor. It supports a flexible file system for transpiling TypeScript into ECMAScript Modules (ESM), imports of external dependencies (including types), making it ideal for developers to create customizable, browser-based IDEs.

https://github.com/bigmistqke/repl/assets/10504064/50195cb6-f3aa-4dea-a40a-d04f2d32479d

Click here for a line-by-line explanation of the above example and here for a live-demo.

Features

  • Monaco Editor Integration: vscode like experience in-browser with Typescript support.
  • Real-time Transpilation: Direct transpilation of TypeScript code into ESM, allowing for immediate feedback.
  • Automatic Imports of External Dependencies: Streamline coding with automatic imports of external dependencies, including type definitions.
  • Flexible API: Direct access to the internals and to the generated ESM Modules. Application can scale from a minimal playground to a feature-complete IDE.
  • Advanced File System Management: Robust management of file states and operations within the editor.
  • Configurable Build and Runtime Options: Easily configurable TypeScript compiler settings and integration with Babel-plugins/presets.

Table of Contents

Installation

Import package from package manager.

npm install `@bigmistqke/repl`
yarn add `@bigmistqke/repl`
pnpm install `@bigmistqke/repl`

Components Documentation

Repl Component

Initializes the Repl environment by dynamically loading the required libraries (Babel, TypeScript and monaco-editor) and any Babel presets/plugins defined in the props. Configures and instantiates ReplContext, which sets up FileSystem and TypeRegistry. The component ensures no children are rendered until all dependencies are fully loaded and the optionally provided onSetup-prop has been resolved.

It provides access for its children to its internal ReplContext through the useRepl-hook.

Usage

<Repl
  typescript={{
    resolveJsonModule: true,
    esModuleInterop: true,
    ...
  }}
  initialState={{
    files: {
      // Add 2 files to the virtual file-system.
      sources: {
        // One exporting a sum-function.
        'src/sum.ts': `export const sum = (a: number, b: number) => a + b`,
        // Another importing this function and exporting a subtract-function.
        'src/index.ts': `import { sum } from "./sum";
          export const sub = (a: number, b: number) => sum(a, b * -1)`,
      }
    }
  }}
  class={styles.repl}
  onSetup={({ fileSystem }) => {
    createEffect(async () => {
      // Get the module-url of the File at a given path.
      const moduleUrl = fileSystem.get('src/index.ts')?.moduleUrl()
      if (!moduleUrl) return
      // Import the subtract-function of the module.
      const { sub } = await import(moduleUrl)
      // Call the function.
      console.log(sub(2, 1)) // Will log 1
    })
  }}
>

Props

  • babel: Configuration for Babel transformations.
    • presets: Array of string identifiers for Babel presets.
    • plugins: Array of plugins or strings for Babel transformations.
  • cdn: Cdn to import external dependencies from, defaults to esm.sh.
  • initialState: Defines the initial state of the filesystem with predefined files and content.
    • files:
      • sources: Record of virtual path and source-code (.js/.jsx/.ts/.tsx/.css).
      • alias: Record of package-name and virtual path.
    • types:
      • sources: Record of virtual path and source-code (.d.ts).
      • alias: Record of package-names and virtual path.
  • mode: Theme mode for the editor, either light or dark.
  • onSetup:
    • A function that runs after the editor setup is complete. It allows access to the ReplContext for custom initialization scripts; for example pre-loading a local package.
    • The initial file-system state will only be processed after this callback returns. This callback can be async.
  • typescript: Configuration options for the TypeScript compiler, equal to tsconfig.json.

Type

type ReplProps = ComponentProps<'div'> & Partial<ReplConfig>

type ReplConfig = {
  babel: {
    presets: string[]
    plugins: (string | babel.PluginItem)[]
  }
  cdn: string
  initialState: {
    files: {
      sources: Record<string, string>
      alias: Record<string, string>
    }
    types: {
      sources: Record<string, string>
      alias: Record<string, string[]>
    }
  }
  mode: 'light' | 'dark'
  onSetup: (replContext: ReplContext) => Promise<void> | void
  typescript: TypescriptConfig
  actions?: {
    saveRepl?: boolean
  }
}

Repl.Editor Component

Repl.Editor embeds a monaco-editor instance for editing files. It dynamically creates a File instance in the virtual FileSystem based on the provided path-prop.

Usage

<Repl.Editor
  style={{ flex: 1 }}
  path={currentPath()}
  onMount={editor => {
    createEffect(on(currentPath, () => editor.focus()))
  }}
/>

Props

  • path: The file path in the virtual file system to bind the editor to.
  • onMount: Callback function that executes when the editor is mounted, with the current monaco-editor as argument.

Type

type EditorProps = ComponentProps<'div'> & {
  path: string
  onMount?: (editor: MonacoEditor) => void
}

Repl.Frame Component

Manages individual <iframe/> containers for isolated execution environments.

Usage

<Repl.Frame
  style={{ flex: 1 }}
  name="frame-2"
  bodyStyle={{
    padding: '0px',
    margin: '0px',
  }}
/>

Props

  • name: An identifier for the (Frame)(#frame). Defaults to default.
  • bodyStyle: CSS properties as a string or JSX.CSSProperties object to apply to the <iframe/> body.

Type

type FrameProps = ComponentProps<'iframe'> &
  Partial<{
    name: string
    bodyStyle: JSX.CSSProperties | string
  }>

Repl.TabBar Component

A minimal wrapper around <For/> to assist with navigating between different files opened in the editor.

Usage

<Repl.TabBar style={{ flex: 1 }}>
  {({ path }) => <button onClick={() => setCurrentPath(path)}>{path}</button>}
</Repl.TabBar>

Props

  • children: A callback with the path and the corresponding File as arguments. Expects a JSX.Element to be returned.
  • paths: An array of strings to filter and sort existing paths.

Type

type TabBarProps = ComponentProps<'div'> & {
  children: (arg: { path: string; file: File | undefined }) => JSXElement
  paths: string[]
}

Hooks

useRepl

Hook to interact with the internal api of @bigmistqke/repl through the ReplContext. This class contains the virtual FileSystem, TypeRegistry and FrameRegistry.

This hook should be used in a descendant of Repl, otherwise it will throw.

Usage

const { frameRegistry, fileSystem } = useRepl()

const frame = frameRegistry.get('default')
const entry = fileSystem.get('src/index.ts')

frame?.injectFile(entry)

Type

type useRepl = (): ReplContext

Internal APIs Documentation

ReplContext

The ReplContext class orchestrates the Repl environment, integrating libraries (babel, typescript and monaco-editor) and managing both the virtual FileSystem and type declarations through the TypeRegistry.

It is accessible from userland through the useRepl-hook.

Key Methods and Properties

  • initialize(): Prepares the FileSystem and TypeRegistry based on the initial configuration, handling the setup of files and types.
  • toJSON(): Serializes the current state of the Repl into JSON format for storage or further manipulation.
  • download(name: string): Allows the downloading of the Repl's current state as a JSON file, facilitating easy sharing and persistence.
  • mapModuleDeclarations(path: string, code: string, callback: Function): Applies transformations to module declarations (imports/exports) within files based on the provided callback function.

Type

class ReplContext {
  constructor(
    public libs: {
      monaco: Monaco,
      typescript: Ts,
      babel: Babel,
      babelPresets: any[],
      babelPlugins: Babel.PluginItem[]
    },
    config: Partial<ReplConfig>,
  )

  // Cdn defaults to `esm.sh`
  config: Mandatory<ReplConfig, 'cdn'>
  fileSystem: FileSystem
  frameRegistry: FrameRegistry
  typeRegistry: TypeRegistry

  initialize(): void
  toJSON(): ReplState
  download(name = 'repl.config.json'): void
  mapModuleDeclarations(path: string, code: string, callback: Function): string | undefined
}

FileSystem

The FileSystem API manages a virtual file system, allowing for the creation, retrieval, and manipulation of files as well as handling imports and exports of modules within the monaco-editor environment.

Key Methods and Properties

  • create(path: string): Creates and returns a new File instance at the specified path.
  • get(path: string): Retrieves a File instance by its path.
  • has(path: string): Checks if a File exists at the specified path.
  • resolve(path: string): Resolves a path according to TypeScript resolution rules, supporting both relative and absolute paths. Returns File or undefined.
  • importFromPackageJson(url: string): Imports a package from a specified URL by parsing its package.json.
  • initialize(): Initializes the file system with the specified initial state, including preloading files and setting aliases.

Type

class FileSystem {
  constructor(
    public repl: ReplContext,
  )

  alias: Record<string, string>
  config: ReplConfig
  packageJsonParser: PackageJsonParser
  typeRegistry: TypeRegistry

  addProject(files: Record<string, string>): void
  all(): Record<string, File>
  create(path: string): File
  get(path: string): File | undefined
  has(path: string): boolean
  importFromPackageJson(url: string): Promise<void>
  initialize(): void
  resolve(path: string): File | undefined
  toJSON(): {
    files: {
      sources: Record<string, string>
      alias: Record<string, string>
    }
    types: {
      sources: Record<string, string>
      alias: Record<string, string[]>
    }
  }
}

JsFile and CssFile

These classes represent JavaScript and CSS files within the virtual file system, respectively. Both extend from the abstract File class, which provides basic file operations and model management.

Key Methods and Properties

  • cachedModuleUrl: A memoized URL for an ES Module, created from the file's source code.
  • dispose(frame: Frame): Runs a cleanup-function to remove any side-effects from the given Frame.
    • CssFile: Removes stylesheet generated by the CssFile esm-module.
    • JsFile: Executes the cleanup function attached to window.dispose in the provided frame. window.dispose is either explicitly mentioned in the code, or it is added through a babel-transformation (see solid-repl-plugin of @bigmistqke/repl/plugins).
  • generateModuleUrl: Generates a new URL for an ES Module based on the current source code of the file.
  • get(): Retrieves the content of the file.
  • set(value: string): Sets the content of the file.
  • model: The Monaco editor model associated with the file.
  • toJSON(): Serializes the file's content.

Types

abstract class File {
  abstract model: Model

  abstract cachedModuleUrl(): string | undefined
  abstract generateModuleUrl(): string | undefined
  abstract get(): string
  abstract set(value: string): void
  abstract toJSON(): string
}
class JsFile extends File {
  // Accessor to the CssFiles that are imported in the current JsFile
  cssImports: Accessor<CssFile[]>
}
class CssFile extends File {}

FrameRegistry

Manages a registry of Frame instances, each associated with its distinct Window. This class handles the creation, retrieval, and management of Frame instances.

Key Methods and Properties

  • add(name: string, window: Window): Adds a new Frame with the given name and window reference.
  • delete(name: string): Removes a Frame from the registry.
  • get(name: string): Retrieves a Frame by name.
  • has(name: string): Checks if a Frame exists by name.
class FrameRegistry {
  add(name: string, window: Window): void
  delete(name: string): void
  get(name: string): Frame
  has(name: string): boolean
}

Frame

Represents an individual <iframe/> within the application. It offers method to inject and execute Javascript and CSS code into its Window.

Key Methods and Properties

  • injectFile(file: File): Injects the moduleUrl of a given File into the frame
class Frame {
  constructor(public window: Window)
  injectFile(file: File): HTMLScriptElement | undefined
}

TypeRegistry

Manages the registry of TypeScript types across the application, facilitating type definition management. It provides utilities for importing recursively TypeScript definitions from either a package-name or a url.

This is used internally to auto-import the type-definitions of external dependencies.

Key Methods and Properties

  • importTypesFromUrl(url: string, packageName?: string): Imports types from a specified URL, optionally associating them with a package name.
  • importTypesFromPackageName(packageName: string): Imports types based on a package name, resolving to CDN paths and managing version conflicts.
  • toJSON(): Serializes the current state of the type registry.

Types

export class TypeRegistry {
  constructor(public repl: ReplContext)

  importTypesFromUrl(url: string, packageName?: string): Promise<Void>
  importTypesFromPackageName(packageName: string): Promise<void>
  toJSON(): {
    sources: Record<string, string>,
    types: Record<string, [string]>,
  }
}

Example Overview

This application demonstrates complex interactions between various components and hooks, designed to facilitate an interactive and intuitive coding environment directly in the browser. Click here for a live-demo.

Detailed Code Explanation

import { Repl, useRepl } from '@bigmistqke/repl'
import { solidReplPlugin } from '@bigmistqke/repl/plugins/solid-repl'
import { Resizable } from 'corvu/resizable'
import { createEffect, createSignal, mapArray, on, onCleanup } from 'solid-js'
import { JsFile } from 'src/logic/file'
import { JsxEmit } from 'typescript'

// Main component defining the application structure
const App = () => {
  // State management for the current file path, initialized to 'src/index.tsx'
  const [currentPath, setCurrentPath] = createSignal('src/index.tsx')

  // Button component for adding new files dynamically to the Repl environment
  const AddButton = () => {
    const repl = useRepl() // Access the Repl context for filesystem operations

    return (
      <button
        onClick={() => {
          let index = 1
          let path = `src/index.tsx`
          // Check for existing files and increment index to avoid naming collisions
          while (repl.fileSystem.has(path)) {
            path = `src/index${index}.tsx`
            index++
          }
          // Create a new file in the file system and set it as the current file
          repl.fileSystem.create(path)
          setCurrentPath(path)
        }}
      >
        add file
      </button>
    )
  }

  // Setting up the editor with configurations for Babel and TypeScript
  return (
    <Repl
      babel={{
        presets: ['babel-preset-solid'], // Babel preset for SolidJS
        plugins: [solidReplPlugin], // Plugin to enhance SolidJS support in Babel
      }}
      typescript={{
        resolveJsonModule: true,
        esModuleInterop: true,
        noEmit: true, // Avoid emitting files during compilation
        isolatedModules: true, // Ensures each file can be transpiled independently
        skipLibCheck: true, // Skip type checking of all declaration files (*.d.ts)
        allowSyntheticDefaultImports: true,
        forceConsistentCasingInFileNames: true,
        noUncheckedIndexedAccess: true,
        paths: {}, // Additional paths for module resolution
        jsx: JsxEmit.Preserve, // Preserve JSX to be handled by another transformer (e.g., Babel)
        jsxImportSource: 'solid-js', // Specify the JSX factory functions import source
        strict: true, // Enable all strict type-checking options
      }}
      initialState={{
        files: {
          'src/index.css': `body { background: blue; }`, // Initial CSS content
          'src/index.tsx': `...JSX code...`, // Initial JS/JSX content
        },
      }}
      class={styles.repl} // CSS class for styling the Repl component
      onSetup={async ({ fileSystem, frameRegistry }) => {
        createEffect(() => {
          const frame = frameRegistry.get('default') // Access the default frame
          if (!frame) return

          const entry = fs.get(currentPath()) // Get the current main file
          if (entry instanceof JsFile) {
            frame.injectFile(entry) // Inject the JS file into the iframe for execution

            // Cleanup action to remove injected scripts on component unmount
            onCleanup(() => frame.window.dispose?.())

            // Process CSS imports and inject them into the iframe
            createEffect(
              mapArray(entry.cssImports, css => createEffect(() => frame.injectFile(css))),
            )
          }
        })
        // Optional: Load external packages dynamically
        /* await fs.importFromPackageJson('./solid-three/package.json') */
      }}
    >
      <Resizable style={{ width: '100vw', height: '100vh', display: 'flex' }}>
        <Resizable.Panel style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
          <div style={{ display: 'flex' }}>
            <Repl.TabBar style={{ flex: 1 }}>
              {({ path }) => <button onClick={() => setCurrentPath(path)}>{path}</button>}
            </Repl.TabBar>
            <AddButton />
          </div>
          <Repl.Editor
            style={{ flex: 1 }}
            path={currentPath()}
            onMount={editor => {
              // Focus the editor on mount and whenever the current file path changes
              createEffect(on(currentPath, () => editor.focus()))
            }}
          />
        </Resizable.Panel>
        <Resizable.Handle />
        <Resizable.Panel style={{ display: 'flex' }}>
          <Repl.Frame
            style={{ flex: 1 }}
            bodyStyle={{ padding: '0px', margin: '0px' }} // Style for the iframe body
          />
        </Resizable.Panel>
      </Resizable>
    </Repl>
  )
}

export default App

Acknowledgements

The main inspiration of this project is my personal favorite IDE: solid-playground. Some LOC are copied directly, p.ex the css- and js-injection into the iframe.