Compare commits

...

9 Commits

Author SHA1 Message Date
b8bff6f129 Merge pull request 'A bunch of changes relating to the codemirror editor component' (#54) from editor-fixes into main
Reviewed-on: #54
2026-01-13 23:00:37 +00:00
23c8d4822a better naming 2026-01-05 13:38:11 -08:00
d1b16ce3e1 Simplify function completions in completions.ts
Removed the buildFunctionCompletion helper and inlined function completion creation for both scope and module methods. Updated module method extraction to use Object.keys for compatibility with the new module structure.
2026-01-05 13:34:18 -08:00
2c7508c2a4 Better edtior 2026-01-05 11:30:32 -08:00
82722ec9e4 Update exports and editor script in package.json
Refactored the exports field to provide explicit entry points for the main module, editor, and editor CSS. Updated the editor script to point to the example server file.
2026-01-05 11:30:23 -08:00
04c2137fe2 Use isDebug 2026-01-05 11:30:02 -08:00
b378756c5d Merge remote-tracking branch 'origin/main' into editor-fixes 2026-01-05 11:22:32 -08:00
02e5ee45e8 Merge branch 'prettier' into editor-fixes 2025-12-17 10:17:17 -08:00
ff58f2b8ae wip 2025-12-17 09:55:37 -08:00
32 changed files with 455 additions and 1387 deletions

View File

@ -1,11 +1,15 @@
{
"name": "shrimp",
"version": "0.1.0",
"exports": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./editor": "./src/editor/index.ts",
"./editor.css": "./src/editor/editor.css"
},
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/server/server.tsx",
"editor": "bun --hot src/editor/example/server.tsx",
"repl": "bun bin/repl",
"update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm",
"cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp",
@ -33,4 +37,4 @@
"singleQuote": true,
"printWidth": 100
}
}
}

View File

@ -1,261 +0,0 @@
export type CommandShape = {
command: string
description?: string
args: ArgShape[]
execute: string | ((...args: any[]) => any)
}
type ArgShape<T extends keyof ArgTypeMap = keyof ArgTypeMap> = {
name: string
type: T
description?: string
optional?: boolean
default?: ArgTypeMap[T]
}
type ArgTypeMap = {
string: string
number: number
boolean: boolean
}
const commandShapes: CommandShape[] = [
{
command: 'ls',
description: 'List the contents of a directory',
execute: './commands/ls.ts',
args: [
{ name: 'path', type: 'string', description: 'The path to list' },
{ name: 'all', type: 'boolean', description: 'Show hidden files', default: false },
{ name: 'long', type: 'boolean', description: 'List in long format', default: false },
{
name: 'short-names',
type: 'boolean',
description: 'Only print file names',
default: false,
},
{ name: 'full-paths', type: 'boolean', description: 'Display full paths', default: false },
],
},
{
command: 'cd',
description: 'Change the current working directory',
execute: './commands/cd.ts',
args: [{ name: 'path', type: 'string', description: 'The path to change to' }],
},
{
command: 'cp',
description: 'Copy files or directories',
execute: './commands/cp.ts',
args: [
{ name: 'source', type: 'string', description: 'Source file or directory' },
{ name: 'destination', type: 'string', description: 'Destination path' },
{ name: 'recursive', type: 'boolean', description: 'Copy recursively', default: false },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'mv',
description: 'Move files or directories',
execute: './commands/mv.ts',
args: [
{ name: 'source', type: 'string', description: 'Source file or directory' },
{ name: 'destination', type: 'string', description: 'Destination path' },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'rm',
description: 'Remove files or directories',
execute: './commands/rm.ts',
args: [
{ name: 'path', type: 'string', description: 'Path to remove' },
{ name: 'recursive', type: 'boolean', description: 'Remove recursively', default: false },
{ name: 'force', type: 'boolean', description: 'Force removal', default: false },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'mkdir',
description: 'Create directories',
execute: './commands/mkdir.ts',
args: [
{ name: 'path', type: 'string', description: 'Directory path to create' },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
],
},
{
command: 'touch',
description: 'Create empty files or update timestamps',
execute: './commands/touch.ts',
args: [
{ name: 'path', type: 'string', description: 'File path to touch' },
{ name: 'access', type: 'boolean', description: 'Update access time only', default: false },
{
name: 'modified',
type: 'boolean',
description: 'Update modified time only',
default: false,
},
],
},
{
command: 'echo',
description: 'Display a string',
execute: './commands/echo.ts',
args: [
{ name: 'text', type: 'string', description: 'Text to display' },
{ name: 'no-newline', type: 'boolean', description: "Don't append newline", default: false },
],
},
{
command: 'cat',
description: 'Display file contents',
execute: './commands/cat.ts',
args: [
{ name: 'path', type: 'string', description: 'File to display' },
{ name: 'numbered', type: 'boolean', description: 'Show line numbers', default: false },
],
},
{
command: 'head',
description: 'Show first lines of input',
execute: './commands/head.ts',
args: [
{ name: 'path', type: 'string', description: 'File to read from' },
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
],
},
{
command: 'tail',
description: 'Show last lines of input',
execute: './commands/tail.ts',
args: [
{ name: 'path', type: 'string', description: 'File to read from' },
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
{ name: 'follow', type: 'boolean', description: 'Follow file changes', default: false },
],
},
{
command: 'grep',
description: 'Search for patterns in text',
execute: './commands/grep.ts',
args: [
{ name: 'pattern', type: 'string', description: 'Pattern to search for' },
{
name: 'ignore-case',
type: 'boolean',
description: 'Case insensitive search',
default: false,
},
{ name: 'invert-match', type: 'boolean', description: 'Invert match', default: false },
{ name: 'line-number', type: 'boolean', description: 'Show line numbers', default: false },
],
},
{
command: 'sort',
description: 'Sort input',
execute: './commands/sort.ts',
args: [
{ name: 'reverse', type: 'boolean', description: 'Sort in reverse order', default: false },
{
name: 'ignore-case',
type: 'boolean',
description: 'Case insensitive sort',
default: false,
},
{ name: 'numeric', type: 'boolean', description: 'Numeric sort', default: false },
],
},
{
command: 'uniq',
description: 'Filter out repeated lines',
execute: './commands/uniq.ts',
args: [
{ name: 'count', type: 'boolean', description: 'Show count of occurrences', default: false },
{
name: 'repeated',
type: 'boolean',
description: 'Show only repeated lines',
default: false,
},
{ name: 'unique', type: 'boolean', description: 'Show only unique lines', default: false },
],
},
{
command: 'select',
description: 'Select specific columns from data',
execute: './commands/select.ts',
args: [{ name: 'columns', type: 'string', description: 'Columns to select' }],
},
{
command: 'where',
description: 'Filter data based on conditions',
execute: './commands/where.ts',
args: [{ name: 'condition', type: 'string', description: 'Filter condition' }],
},
{
command: 'group-by',
description: 'Group data by column values',
execute: './commands/group-by.ts',
args: [{ name: 'column', type: 'string', description: 'Column to group by' }],
},
{
command: 'ps',
description: 'List running processes',
execute: './commands/ps.ts',
args: [
{ name: 'long', type: 'boolean', description: 'Show detailed information', default: false },
],
},
{
command: 'sys',
description: 'Show system information',
execute: './commands/sys.ts',
args: [],
},
{
command: 'which',
description: 'Find the location of a command',
execute: './commands/which.ts',
args: [
{ name: 'command', type: 'string', description: 'Command to locate' },
{ name: 'all', type: 'boolean', description: 'Show all matches', default: false },
],
},
] as const
let commandSource = () => commandShapes
export const setCommandSource = (fn: () => CommandShape[]) => {
commandSource = fn
}
export const resetCommandSource = () => {
commandSource = () => commandShapes
}
export const matchingCommands = (prefix: string) => {
const match = commandSource().find((cmd) => cmd.command === prefix)
const partialMatches = commandSource().filter((cmd) => cmd.command.startsWith(prefix))
return { match, partialMatches }
}

87
src/editor/completions.ts Normal file
View File

@ -0,0 +1,87 @@
import { autocompletion, type CompletionContext, type Completion } from '@codemirror/autocomplete'
import { Shrimp } from '#/index'
const keywords = [
'import',
'end',
'do',
'if',
'else',
'while',
'try',
'catch',
'finally',
'throw',
'not',
'and',
'or',
'true',
'false',
'null',
]
const keywordCompletions: Completion[] = keywords.map((k) => ({
label: k,
type: 'keyword',
boost: -1,
}))
export const createShrimpCompletions = (shrimp: Shrimp) => {
// Build completions from all names in the shrimp scope
const scopeNames = shrimp.vm.scope.vars()
console.log(`🌭`, shrimp.vm.vars())
const functionCompletions: Completion[] = scopeNames.map((name) => {
const value = shrimp.vm.scope.get(name)
let type
if (value?.type === 'function' || value?.type === 'native') {
type = 'function'
} else if (value?.type === 'dict') {
type = 'namespace'
} else {
type = 'variable'
}
return { type, label: name }
})
const allCompletions = [...keywordCompletions, ...functionCompletions]
// Get methods for a module (e.g., math, str, list)
const getModuleMethods = (moduleName: string): Completion[] => {
const module = shrimp.get(moduleName)
if (!module || typeof module !== 'object') return []
return Object.keys(module).map((m) => ({ label: m, type: 'function' }))
}
const shrimpCompletionSource = (context: CompletionContext) => {
// Check for module.method pattern (e.g., "math.")
const dotMatch = context.matchBefore(/[\w\-]+\.[\w\-\?]*/)
if (dotMatch) {
const [moduleName, methodPrefix] = dotMatch.text.split('.')
const methods = getModuleMethods(moduleName!)
if (methods.length > 0) {
const dotPos = dotMatch.from + moduleName!.length + 1
return {
from: dotPos,
options: methods.filter((m) => m.label.startsWith(methodPrefix ?? '')),
}
}
}
// Regular completions
const word = context.matchBefore(/[\w\-\?\$]+/)
if (!word || (word.from === word.to && !context.explicit)) return null
return {
from: word.from,
options: allCompletions.filter((c) => c.label.startsWith(word.text)),
}
}
return autocompletion({
override: [shrimpCompletionSource],
})
}

24
src/editor/diagnostics.ts Normal file
View File

@ -0,0 +1,24 @@
import { linter, type Diagnostic } from '@codemirror/lint'
import { Shrimp } from '#/index'
import { CompilerError } from '#compiler/compilerError'
export const createShrimpDiagnostics = (shrimp: Shrimp) =>
linter((view) => {
const code = view.state.doc.toString()
const diagnostics: Diagnostic[] = []
try {
shrimp.parse(code)
} catch (err) {
if (err instanceof CompilerError) {
diagnostics.push({
from: err.from,
to: err.to,
severity: 'error',
message: err.message,
})
}
}
return diagnostics
})

View File

@ -1,53 +1,78 @@
#output {
flex: 1;
background: var(--bg-output);
color: var(--text-output);
padding: 20px;
overflow-y: auto;
white-space: pre-wrap;
:root {
/* Background colors */
--bg-editor: #011627;
--bg-output: #40318D;
--bg-status-bar: #1E2A4A;
--bg-status-border: #0E1A3A;
--bg-selection: #1D3B53;
/* Text colors */
--text-editor: #D6DEEB;
--text-output: #7C70DA;
--text-status: #B3A9FF55;
--caret: #80A4C2;
/* Syntax highlighting colors */
--color-keyword: #C792EA;
--color-function: #82AAFF;
--color-string: #C3E88D;
--color-number: #F78C6C;
--color-bool: #FF5370;
--color-operator: #89DDFF;
--color-paren: #676E95;
--color-function-call: #FF9CAC;
--color-variable-def: #FFCB6B;
--color-error: #FF6E6E;
--color-regex: #E1ACFF;
/* ANSI terminal colors */
--ansi-black: #011627;
--ansi-red: #FF5370;
--ansi-green: #C3E88D;
--ansi-yellow: #FFCB6B;
--ansi-blue: #82AAFF;
--ansi-magenta: #C792EA;
--ansi-cyan: #89DDFF;
--ansi-white: #D6DEEB;
/* ANSI bright colors (slightly more vibrant) */
--ansi-bright-black: #676E95;
--ansi-bright-red: #FF6E90;
--ansi-bright-green: #D4F6A8;
--ansi-bright-yellow: #FFE082;
--ansi-bright-blue: #A8C7FA;
--ansi-bright-magenta: #E1ACFF;
--ansi-bright-cyan: #A8F5FF;
--ansi-bright-white: #FFFFFF;
}
@font-face {
font-family: 'C64ProMono';
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Pixeloid Mono';
src: url('../../assets/PixeloidMono.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bg-editor);
font-family: 'Pixeloid Mono', 'Courier New', monospace;
font-size: 18px;
height: 100vh;
}
#status-bar {
height: 30px;
background: var(--bg-status-bar);
color: var(--text-status);
display: flex;
align-items: center;
padding: 0 10px;
font-size: 14px;
border-top: 3px solid var(--bg-status-border);
border-bottom: 3px solid var(--bg-status-border);
display: flex;
justify-content: space-between;
}
#status-bar .left,
#status-bar .right {
display: flex;
justify-content: center;
gap: 2rem;
}
#status-bar .multiline {
display: flex;
.dot {
padding-top: 1px;
margin-right: 4px;
}
.active {
color: var(--color-string);
}
.inactive {
color: inherit;
}
}
.syntax-error {
text-decoration: underline dotted var(--color-error);
}
#root {
height: 100vh;
}

View File

@ -1,140 +1,70 @@
import { EditorView } from '@codemirror/view'
import { asciiEscapeToHtml, assertNever, log, toElement } from '#utils/utils'
import { Signal } from '#utils/signal'
import { getContent } from '#editor/plugins/persistence'
import type { HtmlEscapedString } from 'hono/utils/html'
import { connectToNose, noseSignals } from '#editor/noseClient'
import type { Value } from 'reefvm'
import { Compartment } from '@codemirror/state'
import { lineNumbers } from '@codemirror/view'
import { shrimpSetup } from '#editor/plugins/shrimpSetup'
import { EditorView, basicSetup } from 'codemirror'
import { Shrimp } from '#/index'
import '#editor/editor.css'
import { shrimpTheme } from './theme'
import { createShrimpDiagnostics } from './diagnostics'
import { shrimpHighlighter } from './highlighter'
import { createShrimpCompletions } from './completions'
import { shrimpKeymap } from './keymap'
import { getContent, persistence } from './persistence'
const lineNumbersCompartment = new Compartment()
type EditorProps = {
initialCode?: string
onChange?: (code: string) => void
extensions?: import('@codemirror/state').Extension[]
shrimp?: Shrimp
}
connectToNose()
export const outputSignal = new Signal<Value | string>()
export const errorSignal = new Signal<string>()
export const multilineModeSignal = new Signal<boolean>()
export const Editor = () => {
export const Editor = ({
initialCode = '',
onChange,
extensions: customExtensions = [],
shrimp = new Shrimp(),
}: EditorProps) => {
return (
<>
<div
ref={(ref: Element) => {
if (ref?.querySelector('.cm-editor')) return
const view = new EditorView({
parent: ref,
doc: getContent(),
extensions: shrimpSetup(lineNumbersCompartment),
})
multilineModeSignal.connect((isMultiline) => {
view.dispatch({
effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []),
})
})
requestAnimationFrame(() => view.focus())
}}
/>
<div id="status-bar">
<div className="left"></div>
<div className="right"></div>
</div>
<div id="output"></div>
<div id="error"></div>
</>
<div
ref={(el: Element) => {
if (!el?.querySelector('.cm-editor'))
createEditorView(el, getContent() ?? initialCode, onChange, customExtensions, shrimp)
}}
/>
)
}
noseSignals.connect((message) => {
if (message.type === 'error') {
log.error(`Nose error: ${message.data}`)
errorSignal.emit(`Nose error: ${message.data}`)
} else if (message.type === 'reef-output') {
const x = outputSignal.emit(message.data)
} else if (message.type === 'connected') {
outputSignal.emit(`╞ Connected to Nose VM`)
const createEditorView = (
el: Element,
initialCode: string,
onChange: ((code: string) => void) | undefined,
customExtensions: import('@codemirror/state').Extension[],
shrimp: Shrimp
) => {
const extensions = [
basicSetup,
shrimpTheme,
createShrimpDiagnostics(shrimp),
createShrimpCompletions(shrimp),
shrimpHighlighter,
shrimpKeymap,
persistence,
...customExtensions,
]
if (onChange) {
extensions.push(
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString())
}
})
)
}
})
outputSignal.connect((value) => {
const el = document.querySelector('#output')!
el.innerHTML = ''
el.innerHTML = asciiEscapeToHtml(valueToString(value))
})
errorSignal.connect((error) => {
const el = document.querySelector('#output')!
el.innerHTML = ''
el.classList.add('error')
el.innerHTML = asciiEscapeToHtml(error)
})
type StatusBarMessage = {
side: 'left' | 'right'
message: string | Promise<HtmlEscapedString>
className: string
order?: number
}
export const statusBarSignal = new Signal<StatusBarMessage>()
statusBarSignal.connect(async ({ side, message, className, order }) => {
document.querySelector(`#status-bar .${className}`)?.remove()
const sideEl = document.querySelector(`#status-bar .${side}`)!
const messageEl = (
<div data-order={order ?? 0} className={className}>
{await message}
</div>
)
// Now go through the nodes and put it in the right spot based on order. Higher number means further right
const nodes = Array.from(sideEl.childNodes)
const index = nodes.findIndex((node) => {
if (!(node instanceof HTMLElement)) return false
return Number(node.dataset.order) > (order ?? 0)
new EditorView({
parent: el,
doc: initialCode,
extensions,
})
if (index === -1) {
sideEl.appendChild(toElement(messageEl))
} else {
sideEl.insertBefore(toElement(messageEl), nodes[index]!)
}
})
const valueToString = (value: Value | string): string => {
if (typeof value === 'string') {
return value
}
switch (value.type) {
case 'null':
return 'null'
case 'boolean':
return value.value ? 'true' : 'false'
case 'number':
return value.value.toString()
case 'string':
return value.value
case 'array':
return `${value.value.map(valueToString).join('\n')}`
case 'dict': {
const entries = Array.from(value.value.entries()).map(
([key, val]) => `"${key}": ${valueToString(val)}`,
)
return `{${entries.join(', ')}}`
}
case 'regex':
return `/${value.value.source}/`
case 'function':
return `<function>`
case 'native':
return `<function ${value.fn.name}>`
default:
assertNever(value)
return `<unknown value type: ${(value as any).type}>`
}
// Trigger onChange with initial content
onChange?.(initialCode)
}

View File

@ -0,0 +1,6 @@
import { Editor } from '#editor/editor'
import { render } from 'hono/jsx/dom'
if (typeof document !== 'undefined') {
render(<Editor initialCode={'# type some code'} />, document.getElementById('root')!)
}

View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="../editor.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,14 @@
import index from './index.html'
const server = Bun.serve({
port: 3000,
routes: {
'/': index,
},
development: {
hmr: true,
console: true,
},
})
console.log(`Editor running at ${server.url}`)

73
src/editor/highlighter.ts Normal file
View File

@ -0,0 +1,73 @@
import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { RangeSetBuilder } from '@codemirror/state'
import { Shrimp } from '#/index'
import { type SyntaxNode } from '#parser/node'
import { log } from '#utils/utils'
const shrimp = new Shrimp()
export const shrimpHighlighter = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.highlight(view)
}
update(update: ViewUpdate) {
if (!update.docChanged) return
this.decorations = this.highlight(update.view)
}
highlight(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
const code = view.state.doc.toString()
try {
const tree = shrimp.parse(code)
const decorations: { from: number; to: number; class: string }[] = []
tree.iterate({
enter: (node) => {
const cls = tokenStyles[node.type.name]
const isLeaf = node.children.length === 0
if (cls && isLeaf) {
decorations.push({ from: node.from, to: node.to, class: cls })
}
},
})
// Sort by position (required by RangeSetBuilder)
decorations.sort((a, b) => a.from - b.from)
for (const d of decorations) {
builder.add(d.from, d.to, Decoration.mark({ class: d.class }))
}
} catch (error) {
log('Parsing error in highlighter', error)
}
return builder.finish()
}
},
{
decorations: (v) => v.decorations,
}
)
// Map node types to CSS classes
const tokenStyles: Record<string, string> = {
keyword: 'tok-keyword',
String: 'tok-string',
StringFragment: 'tok-string',
CurlyString: 'tok-string',
Number: 'tok-number',
Boolean: 'tok-bool',
Null: 'tok-null',
Identifier: 'tok-identifier',
AssignableIdentifier: 'tok-variable-def',
Comment: 'tok-comment',
operator: 'tok-operator',
Regex: 'tok-regex',
}

1
src/editor/index.ts Normal file
View File

@ -0,0 +1 @@
export { Editor } from './editor'

8
src/editor/keymap.ts Normal file
View File

@ -0,0 +1,8 @@
import { keymap } from '@codemirror/view'
import { acceptCompletion } from '@codemirror/autocomplete'
import { indentWithTab } from '@codemirror/commands'
export const shrimpKeymap = keymap.of([
{ key: 'Tab', run: acceptCompletion },
indentWithTab,
])

View File

@ -1,59 +0,0 @@
import { Signal } from '#utils/signal'
import type { Bytecode, Value } from 'reefvm'
let ws: WebSocket
type IncomingMessage =
| { type: 'connected' }
| { type: 'ping'; data: number }
| { type: 'commands'; data: number }
| {
type: 'apps'
data: {
name: string
type: 'browser' | 'server'
}[]
}
| {
type: 'session:start'
data: {
NOSE_DIR: string
cwd: string
hostname: string
mode: string
project: string
}
}
| { type: 'reef-output'; data: Value }
| { type: 'error'; data: string }
export const noseSignals = new Signal<IncomingMessage>()
export const connectToNose = (url: string = 'ws://localhost:3000/ws') => {
ws = new WebSocket(url)
ws.onopen = () => noseSignals.emit({ type: 'connected' })
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
noseSignals.emit(message)
}
ws.onerror = (event) => {
console.error(`💥WebSocket error:`, event)
}
ws.onclose = () => {
console.log(`🚪 Connection closed`)
}
}
let id = 0
export const sendToNose = (code: Bytecode) => {
if (!ws) {
throw new Error('WebSocket is not connected.')
} else if (ws.readyState !== WebSocket.OPEN) {
throw new Error(`WebSocket is not open, current status is ${ws.readyState}.`)
}
id += 1
ws.send(JSON.stringify({ type: 'reef-bytecode', data: code, id }))
}

View File

@ -1,6 +1,6 @@
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
export const persistencePlugin = ViewPlugin.fromClass(
export const persistence = ViewPlugin.fromClass(
class {
saveTimeout?: ReturnType<typeof setTimeout>
@ -17,13 +17,13 @@ export const persistencePlugin = ViewPlugin.fromClass(
destroy() {
if (this.saveTimeout) clearTimeout(this.saveTimeout)
}
},
}
)
export const getContent = () => {
return localStorage.getItem('shrimp-editor-content') || ''
}
const setContent = (data: string) => {
export const setContent = (data: string) => {
localStorage.setItem('shrimp-editor-content', data)
}

View File

@ -1,9 +0,0 @@
import { errorSignal } from '#editor/editor'
import { EditorView } from '@codemirror/view'
export const catchErrors = EditorView.exceptionSink.of((exception) => {
console.error('CodeMirror error:', exception)
errorSignal.emit(
`Editor error: ${exception instanceof Error ? exception.message : String(exception)}`,
)
})

View File

@ -1,35 +0,0 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { statusBarSignal } from '#editor/editor'
export const debugTags = ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.geometryChanged) {
this.updateStatusBar(update.view)
}
}
updateStatusBar(view: EditorView) {
const pos = view.state.selection.main.head + 1
const tree = syntaxTree(view.state)
let tags: string[] = []
let node = tree.resolveInner(pos, -1)
while (node) {
tags.push(node.type.name)
node = node.parent!
if (!node) break
}
const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes'
statusBarSignal.emit({
side: 'right',
message: debugText,
className: 'debug-tags',
order: -1,
})
}
},
)

View File

@ -1,62 +0,0 @@
import { parser } from '#parser/shrimp'
import type { Timeout } from '#utils/utils'
import { Range } from '@codemirror/state'
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
type DecorationSet,
} from '@codemirror/view'
export const shrimpErrors = ViewPlugin.fromClass(
class {
timeout?: Timeout
decorations: DecorationSet = Decoration.none
constructor(view: EditorView) {
this.updateErrors(view)
}
update(update: ViewUpdate) {
if (update.docChanged) {
this.debounceUpdate(update.view)
}
}
updateErrors(view: EditorView) {
this.decorations = Decoration.none
try {
const decorations: Range<Decoration>[] = []
const tree = parser.parse(view.state.doc.toString())
tree.iterate({
enter: (node) => {
if (!node.type.isError) return
// Skip empty error nodes
if (node.from === node.to) return
const decoration = Decoration.mark({
class: 'syntax-error',
attributes: { title: 'COREY REPLACE THIS' },
}).range(node.from, node.to)
decorations.push(decoration)
},
})
this.decorations = Decoration.set(decorations)
// requestAnimationFrame(() => view.dispatch({}))
} catch (e) {
console.error('🙈 Error parsing document', e)
}
}
debounceUpdate = (view: EditorView) => {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => this.updateErrors(view), 250)
}
},
{
decorations: (v) => v.decorations,
},
)

View File

@ -1,232 +0,0 @@
import {
ViewPlugin,
ViewUpdate,
EditorView,
Decoration,
type DecorationSet,
} from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { type SyntaxNode } from '@lezer/common'
import { WidgetType } from '@codemirror/view'
import { toElement } from '#utils/utils'
import { matchingCommands } from '#editor/commands'
import * as Terms from '#parser/shrimp.terms'
const ghostTextTheme = EditorView.theme({
'.ghost-text': {
color: '#666',
opacity: '0.6',
fontStyle: 'italic',
},
})
type Hint = { cursor: number; hintText?: string; completionText?: string }
export const inlineHints = [
ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none
currentHint?: Hint
update(update: ViewUpdate) {
if (!update.docChanged && !update.selectionSet) return
this.clearHints()
let hint = this.getContext(update.view)
this.currentHint = hint
this.showHint(hint)
}
handleTab(view: EditorView) {
if (!this.currentHint?.completionText) return false
this.decorations = Decoration.none
view.dispatch({
changes: {
from: this.currentHint.cursor,
insert: this.currentHint.completionText,
},
selection: {
anchor: this.currentHint.cursor + this.currentHint.completionText.length,
},
})
this.currentHint = undefined
return true
}
clearHints() {
this.currentHint = undefined
this.decorations = Decoration.none
}
getContext(view: EditorView): Hint {
const cursor = view.state.selection.main.head
const isCursorAtEnd = cursor === view.state.doc.length
if (!isCursorAtEnd) return { cursor }
const token = this.getCommandContextToken(view, cursor)
if (!token) return { cursor }
const text = view.state.doc.sliceString(token.from, token.to)
const tokenId = token.type.id
let completionText = ''
let hintText = ''
const justSpaces = view.state.doc.sliceString(cursor - 1, cursor) === ' '
if (tokenId === Terms.CommandPartial) {
const { partialMatches } = matchingCommands(text)
const match = partialMatches[0]
if (match) {
completionText = match.command.slice(text.length) + ' '
hintText = completionText
}
} else if (
tokenId === Terms.Identifier &&
token.parent?.type.id === Terms.Arg &&
!justSpaces
) {
const { availableArgs } = this.getCommandContext(view, token)
const matchingArgs = availableArgs.filter((arg) => arg.name.startsWith(text))
const match = matchingArgs[0]
if (match) {
hintText = `${match.name.slice(text.length)}=<${match.type}>`
completionText = `${match.name.slice(text.length)}=`
}
} else if (this.containedBy(token, Terms.PartialNamedArg)) {
const { availableArgs } = this.getCommandContext(view, token)
const textWithoutEquals = text.slice(0, -1)
const matchingArgs = availableArgs.filter((arg) => arg.name == textWithoutEquals)
const match = matchingArgs[0]
if (match) {
hintText = `<${match.type}>`
completionText = 'default' in match ? `${match.default}` : ''
}
} else {
const { availableArgs } = this.getCommandContext(view, token)
const nextArg = Array.from(availableArgs)[0]
const space = justSpaces ? '' : ' '
if (nextArg) {
hintText = `${space}${nextArg.name}=<${nextArg.type}>`
if (nextArg) {
completionText = `${space}${nextArg.name}=`
}
}
}
return { completionText, hintText, cursor }
}
getCommandContextToken(view: EditorView, cursor: number) {
const tree = syntaxTree(view.state)
let node = tree.resolveInner(cursor, -1)
// If we're in a CommandCall, return the token before cursor
if (this.containedBy(node, Terms.CommandCall)) {
return tree.resolveInner(cursor, -1)
}
// If we're in Program, look backward
while (node.name === 'Program' && cursor > 0) {
cursor -= 1
node = tree.resolveInner(cursor, -1)
if (this.containedBy(node, Terms.CommandCall)) {
return tree.resolveInner(cursor, -1)
}
}
}
containedBy(node: SyntaxNode, nodeId: number): SyntaxNode | undefined {
let current: SyntaxNode | undefined = node
while (current) {
if (current.type.id === nodeId) {
return current
}
current = current.parent ?? undefined
}
}
showHint(hint: Hint) {
if (!hint.hintText) return
const widget = new GhostTextWidget(hint.hintText)
const afterCursor = 1
const decoration = Decoration.widget({ widget, side: afterCursor }).range(hint.cursor)
this.decorations = Decoration.set([decoration])
}
getCommandContext(view: EditorView, currentToken: SyntaxNode) {
let commandCallNode = currentToken.parent
while (commandCallNode?.type.name !== 'CommandCall') {
if (!commandCallNode) {
throw new Error('No CommandCall parent found, must be an error in the grammar')
}
commandCallNode = commandCallNode.parent
}
const commandToken = commandCallNode.firstChild
if (!commandToken) {
throw new Error('CommandCall has no children, must be an error in the grammar')
}
const commandText = view.state.doc.sliceString(commandToken.from, commandToken.to)
const { match: commandShape } = matchingCommands(commandText)
if (!commandShape) {
throw new Error(`No command shape found for command "${commandText}"`)
}
let availableArgs = [...commandShape.args]
// Walk through all NamedArg children
let child = commandToken.nextSibling
while (child) {
console.log('child', child.type.name, child.to - child.from)
if (child.type.id === Terms.NamedArg) {
const argName = child.firstChild // Should be the Identifier
if (argName) {
const argText = view.state.doc.sliceString(argName.from, argName.to - 1)
availableArgs = availableArgs.filter((arg) => arg.name !== argText)
}
} else if (child.type.id == Terms.Arg) {
const hasSpaceAfter = view.state.doc.sliceString(child.to, child.to + 1) === ' '
if (hasSpaceAfter) {
availableArgs.shift()
}
}
child = child.nextSibling
}
return { commandShape, availableArgs }
}
},
{
decorations: (v) => v.decorations,
eventHandlers: {
keydown(event, view) {
if (event.key === 'Tab') {
event.preventDefault()
const plugin = view.plugin(inlineHints[0]! as ViewPlugin<any>)
plugin?.handleTab(view)
}
},
},
},
),
ghostTextTheme,
]
class GhostTextWidget extends WidgetType {
constructor(private text: string) {
super()
}
toDOM() {
const el = <span className="ghost-text">{this.text}</span>
return toElement(el)
}
}

View File

@ -1,184 +0,0 @@
import { multilineModeSignal, outputSignal } from '#editor/editor'
import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
let multilineMode = false
const customKeymap = keymap.of([
{
key: 'Enter',
run: (view) => {
if (multilineMode) return false
const input = view.state.doc.toString()
history.push(input)
runCode(input)
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '' },
selection: { anchor: 0 },
})
return true
},
},
{
key: 'Shift-Enter',
run: (view) => {
if (multilineMode) {
const input = view.state.doc.toString()
runCode(input)
return true
} else {
outputSignal.emit('Press Shift+Enter to insert run the code.')
}
multilineModeSignal.emit(true)
multilineMode = true
view.dispatch({
changes: { from: view.state.doc.length, insert: '\n' },
selection: { anchor: view.state.doc.length + 1 },
})
return true
},
},
{
key: 'Tab',
preventDefault: true,
run: (view) => {
view.dispatch({
changes: { from: view.state.selection.main.from, insert: ' ' },
selection: { anchor: view.state.selection.main.from + 2 },
})
return true
},
},
{
key: 'ArrowUp',
run: (view) => {
if (multilineMode) return false
const command = history.previous()
if (command === undefined) return false
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: command },
selection: { anchor: command.length },
})
return true
},
},
{
key: 'ArrowDown',
run: (view) => {
if (multilineMode) return false
const command = history.next()
if (command === undefined) return false
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: command },
selection: { anchor: command.length },
})
return true
},
},
{
key: 'Mod-k 1',
preventDefault: true,
run: (view) => {
const input = view.state.doc.toString()
printParserOutput(input)
return true
},
},
{
key: 'Mod-k 2',
preventDefault: true,
run: (view) => {
const input = view.state.doc.toString()
printBytecodeOutput(input)
return true
},
},
])
let firstTime = true
const singleLineFilter = EditorState.transactionFilter.of((transaction) => {
if (multilineMode) return transaction // Allow everything in multiline mode
if (firstTime) {
firstTime = false
if (transaction.newDoc.toString().includes('\n')) {
multilineMode = true
return transaction
}
}
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
if (inserted.toString().includes('\n')) {
multilineMode = true
return
}
})
return transaction
})
export const shrimpKeymap = [customKeymap, singleLineFilter]
class History {
private commands: string[] = []
private index: number | undefined
private storageKey = 'shrimp-command-history'
constructor() {
try {
this.commands = JSON.parse(localStorage.getItem(this.storageKey) || '[]')
} catch {
console.warn('Failed to load command history from localStorage')
}
}
push(command: string) {
this.commands.push(command)
// Limit to last 50 commands
this.commands = this.commands.slice(-50)
localStorage.setItem(this.storageKey, JSON.stringify(this.commands))
this.index = undefined
}
previous(): string | undefined {
if (this.commands.length === 0) return
if (this.index === undefined) {
this.index = this.commands.length - 1
} else if (this.index > 0) {
this.index -= 1
}
return this.commands[this.index]
}
next(): string | undefined {
if (this.commands.length === 0 || this.index === undefined) return
if (this.index < this.commands.length - 1) {
this.index += 1
return this.commands[this.index]
} else {
this.index = undefined
return ''
}
}
}
const history = new History()

View File

@ -1,9 +0,0 @@
import { parser } from '#/parser/shrimp'
import { LRLanguage, LanguageSupport } from '@codemirror/language'
import { highlighting } from '#/parser/highlight.js'
const language = LRLanguage.define({
parser: parser.configure({ props: [highlighting] }),
})
export const shrimpLanguage = new LanguageSupport(language)

View File

@ -1,35 +0,0 @@
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
import { bracketMatching, indentOnInput } from '@codemirror/language'
import { highlightSpecialChars, drawSelection, dropCursor, keymap } from '@codemirror/view'
import { closeBrackets, autocompletion, completionKeymap } from '@codemirror/autocomplete'
import { EditorState, Compartment } from '@codemirror/state'
import { searchKeymap } from '@codemirror/search'
import { shrimpKeymap } from './keymap'
import { shrimpTheme, shrimpHighlighting } from './theme'
import { shrimpLanguage } from './shrimpLanguage'
import { shrimpErrors } from './errors'
import { persistencePlugin } from './persistence'
import { catchErrors } from './catchErrors'
export const shrimpSetup = (lineNumbersCompartment: Compartment) => {
return [
catchErrors,
shrimpKeymap,
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
bracketMatching(),
closeBrackets(),
autocompletion(),
indentOnInput(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap]),
lineNumbersCompartment.of([]),
shrimpTheme,
shrimpLanguage,
shrimpHighlighting,
shrimpErrors,
persistencePlugin,
]
}

View File

@ -1,60 +0,0 @@
import { EditorView } from '@codemirror/view'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--color-keyword)' },
{ tag: tags.name, color: 'var(--color-function)' },
{ tag: tags.string, color: 'var(--color-string)' },
{ tag: tags.number, color: 'var(--color-number)' },
{ tag: tags.bool, color: 'var(--color-bool)' },
{ tag: tags.operator, color: 'var(--color-operator)' },
{ tag: tags.paren, color: 'var(--color-paren)' },
{ tag: tags.regexp, color: 'var(--color-regex)' },
{ tag: tags.function(tags.variableName), color: 'var(--color-function-call)' },
{ tag: tags.function(tags.invalid), color: 'white' },
{
tag: tags.definition(tags.variableName),
color: 'var(--color-variable-def)',
backgroundColor: 'var(--bg-variable-def)',
padding: '1px 2px',
borderRadius: '2px',
fontWeight: '500',
},
])
export const shrimpHighlighting = syntaxHighlighting(highlightStyle)
export const shrimpTheme = EditorView.theme(
{
'&': {
color: 'var(--text-editor)',
backgroundColor: 'var(--bg-editor)',
height: '100%',
fontSize: '18px',
},
'.cm-content': {
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
caretColor: 'var(--caret)',
padding: '0px',
},
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--caret)',
},
'&.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'var(--bg-selection)',
},
'.cm-editor': {
border: 'none',
outline: 'none',
height: '100%',
},
'.cm-matchingBracket': {
backgroundColor: 'var(--color-bool)',
},
'.cm-nonmatchingBracket': {
backgroundColor: 'var(--color-string)',
},
},
{ dark: true },
)

View File

@ -1,38 +0,0 @@
import { outputSignal, errorSignal } from '#editor/editor'
import { Compiler } from '#compiler/compiler'
import { errorMessage, log } from '#utils/utils'
import { bytecodeToString } from 'reefvm'
import { parser } from '#parser/shrimp'
import { sendToNose } from '#editor/noseClient'
import { treeToString } from '#utils/tree'
export const runCode = async (input: string) => {
try {
const compiler = new Compiler(input)
sendToNose(compiler.bytecode)
} catch (error) {
log.error(error)
errorSignal.emit(`${errorMessage(error)}`)
}
}
export const printParserOutput = (input: string) => {
try {
const cst = parser.parse(input)
const string = treeToString(cst, input)
outputSignal.emit(string)
} catch (error) {
log.error(error)
errorSignal.emit(`${errorMessage(error)}`)
}
}
export const printBytecodeOutput = (input: string) => {
try {
const compiler = new Compiler(input)
outputSignal.emit(bytecodeToString(compiler.bytecode))
} catch (error) {
log.error(error)
errorSignal.emit(`${errorMessage(error)}`)
}
}

51
src/editor/theme.ts Normal file
View File

@ -0,0 +1,51 @@
import { EditorView } from '@codemirror/view'
export const shrimpTheme = EditorView.theme(
{
'&': {
color: 'var(--text-editor)',
backgroundColor: 'var(--bg-editor)',
height: '100%',
fontSize: '18px',
},
'.cm-content': {
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
caretColor: 'var(--caret)',
padding: '0px',
borderTop: '1px solid var(--bg-editor)',
},
'&.cm-focused .cm-cursor': {
borderLeftColor: 'var(--caret)',
},
'.cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--bg-selection)',
},
'.cm-editor': {
border: 'none',
outline: 'none',
height: '100%',
},
'.cm-matchingBracket': {
backgroundColor: 'var(--color-bool)',
},
'.cm-nonmatchingBracket': {
backgroundColor: 'var(--color-string)',
},
'.cm-activeLine': {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
},
// Token highlighting
'.tok-keyword': { color: 'var(--color-keyword)' },
'.tok-string': { color: 'var(--color-string)' },
'.tok-number': { color: 'var(--color-number)' },
'.tok-bool': { color: 'var(--color-bool)' },
'.tok-null': { color: 'var(--color-number)', fontStyle: 'italic' },
'.tok-identifier': { color: 'var(--color-function)' },
'.tok-variable-def': { color: 'var(--color-variable-def)' },
'.tok-comment': { color: 'var(--ansi-bright-black)', fontStyle: 'italic' },
'.tok-operator': { color: 'var(--color-operator)' },
'.tok-regex': { color: 'var(--color-regex)' },
},
{ dark: true }
)

View File

@ -1,4 +1,4 @@
const DEBUG = process.env.DEBUG || false
import { isDebug } from '#utils/utils'
export type Token = {
type: TokenType
@ -169,11 +169,11 @@ export class Scanner {
from,
to,
},
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {},
),
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}
)
)
if (DEBUG) {
if (isDebug()) {
const tok = this.tokens.at(-1)
console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value)
}

View File

@ -19,6 +19,7 @@ import { list } from './list'
import { math } from './math'
import { str } from './str'
import { types } from './types'
import { runningInBrowser } from '#utils/utils'
export const globals: Record<string, any> = {
date,
@ -31,17 +32,28 @@ export const globals: Record<string, any> = {
str,
// shrimp runtime info
$: {
args: Bun.argv.slice(3),
argv: Bun.argv.slice(1),
env: process.env,
pid: process.pid,
cwd: process.env.PWD,
script: {
name: Bun.argv[2] || '(shrimp)',
path: resolve(join('.', Bun.argv[2] ?? '')),
},
},
$: runningInBrowser
? {
args: [],
argv: [],
env: {},
pid: 0,
cwd: '',
script: {
name: '',
path: '.',
},
} : {
args: Bun.argv.slice(3),
argv: Bun.argv.slice(1),
env: process.env,
pid: process.pid,
cwd: process.env.PWD,
script: {
name: Bun.argv[2] || '(shrimp)',
path: resolve(join('.', Bun.argv[2] ?? '')),
},
}
// hello
echo: (...args: any[]) => {
@ -49,7 +61,7 @@ export const globals: Record<string, any> = {
...args.map((a) => {
const v = toValue(a)
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
}),
})
)
return toValue(null)
},
@ -120,7 +132,7 @@ export const globals: Record<string, any> = {
const idx = typeof index === 'number' ? index : parseInt(index as string)
if (idx < 0 || idx >= value.value.length) {
throw new Error(
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`,
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`
)
}
return value.value[idx]

View File

@ -1,10 +0,0 @@
import { Editor } from '#/editor/editor'
import { render } from 'hono/jsx/dom'
import './index.css'
const App = () => {
return <Editor />
}
const root = document.getElementById('root')!
render(<App />, root)

View File

@ -1,84 +0,0 @@
:root {
/* Background colors */
--bg-editor: #011627;
--bg-output: #40318d;
--bg-status-bar: #1e2a4a;
--bg-status-border: #0e1a3a;
--bg-selection: #1d3b53;
--bg-variable-def: #1e2a4a;
/* Text colors */
--text-editor: #d6deeb;
--text-output: #7c70da;
--text-status: #b3a9ff55;
--caret: #80a4c2;
/* Syntax highlighting colors */
--color-keyword: #c792ea;
--color-function: #82aaff;
--color-string: #c3e88d;
--color-number: #f78c6c;
--color-bool: #ff5370;
--color-operator: #89ddff;
--color-paren: #676e95;
--color-function-call: #ff9cac;
--color-variable-def: #ffcb6b;
--color-error: #ff6e6e;
--color-regex: #e1acff;
/* ANSI terminal colors */
--ansi-black: #011627;
--ansi-red: #ff5370;
--ansi-green: #c3e88d;
--ansi-yellow: #ffcb6b;
--ansi-blue: #82aaff;
--ansi-magenta: #c792ea;
--ansi-cyan: #89ddff;
--ansi-white: #d6deeb;
/* ANSI bright colors (slightly more vibrant) */
--ansi-bright-black: #676e95;
--ansi-bright-red: #ff6e90;
--ansi-bright-green: #d4f6a8;
--ansi-bright-yellow: #ffe082;
--ansi-bright-blue: #a8c7fa;
--ansi-bright-magenta: #e1acff;
--ansi-bright-cyan: #a8f5ff;
--ansi-bright-white: #ffffff;
}
@font-face {
font-family: 'C64ProMono';
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Pixeloid Mono';
src: url('../../assets/PixeloidMono.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg-output);
color: var(--text-output);
font-family: 'Pixeloid Mono', 'Courier New', monospace;
font-size: 18px;
height: 100vh;
overflow: hidden;
}
#root {
height: 100vh;
background: var(--bg-output);
display: flex;
flex-direction: column;
}

View File

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shrimp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./app.tsx"></script>
</body>
</html>

View File

@ -1,29 +0,0 @@
import index from './index.html'
const server = Bun.serve({
port: process.env.PORT ? Number(process.env.PORT) : 3001,
routes: {
'/*': index,
'/api/hello': {
async GET(req) {
return Response.json({
message: 'Hello, world!',
method: 'GET',
})
},
async PUT(req) {
return Response.json({
message: 'Hello, world!',
method: 'PUT',
})
},
},
},
development: process.env.NODE_ENV !== 'production' && {
hmr: true,
console: true,
},
})
console.log(`🚀 Server running at ${server.url}`)

View File

@ -1,68 +0,0 @@
/**
* How to use a Signal:
*
* Create a signal with primitives:
* const nameSignal = new Signal<string>()
* const countSignal = new Signal<number>()
*
* Create a signal with objects:
* const chatSignal = new Signal<{ username: string, message: string }>()
*
* Create a signal with no data (void):
* const clickSignal = new Signal<void>()
* const clickSignal2 = new Signal() // Defaults to void
*
* Connect to the signal:
* const disconnect = chatSignal.connect((data) => {
* const {username, message} = data;
* console.log(`${username} said "${message}"`);
* })
*
* Emit a signal:
* nameSignal.emit("Alice")
* countSignal.emit(42)
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
* clickSignal.emit() // No argument for void signals
*
* Forward a signal:
* const relaySignal = new Signal<{ username: string, message: string }>()
* const disconnectRelay = chatSignal.connect(relaySignal)
* // Now, when chatSignal emits, relaySignal will also emit the same data
*
* Disconnect a single listener:
* disconnect(); // The disconnect function is returned when you connect to a signal
*
* Disconnect all listeners:
* chatSignal.disconnect()
*/
export class Signal<T = void> {
private listeners: Array<(data: T) => void> = []
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
let listener: (data: T) => void
// If it is a signal, forward the data to the signal
if (listenerOrSignal instanceof Signal) {
listener = (data: T) => listenerOrSignal.emit(data)
} else {
listener = listenerOrSignal
}
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
emit(data: T) {
for (const listener of this.listeners) {
listener(data)
}
}
disconnect() {
this.listeners = []
}
}

View File

@ -136,3 +136,12 @@ export const asciiEscapeToHtml = (str: string): HtmlEscapedString => {
return result as HtmlEscapedString
}
export const isDebug = (): boolean => {
if (typeof process !== 'undefined' && process.env) {
return !!process.env.DEBUG
}
return false
}
export const runningInBrowser = typeof window !== 'undefined'