diff --git a/bun.lockb b/bun.lockb index f2cccee..0133c13 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 65d8041..cb3dc29 100644 --- a/package.json +++ b/package.json @@ -15,21 +15,24 @@ "@astrojs/svelte": "^5.4.0", "@astrojs/tailwind": "^5.1.0", "@php-wasm/web": "^0.7.20", - "astro": "^4.9.2", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "astro": "^4.9.3", "astro-icon": "^1.1.0", "fast-deep-equal": "^3.1.3", "monaco-editor": "^0.49.0", + "monaco-vim": "^0.4.1", "pyodide": "^0.26.0", - "tailwindcss": "^3.4.3", + "tailwindcss": "^3.4.4", "typescript": "^5.4.5" }, "devDependencies": { - "@iconify-json/lucide": "^1.1.189", + "@iconify-json/lucide": "^1.1.190", "@iconify/svelte": "^4.0.2", "@tailwindcss/typography": "^0.5.13", "@types/color": "^3.0.6", "color": "^4.2.3", - "daisyui": "^4.11.1", + "daisyui": "^4.12.2", "svelte": "5.0.0-next.133", "vite-plugin-static-copy": "^1.0.5" } diff --git a/src/adapters/storage.svelte.ts b/src/adapters/storage.svelte.ts new file mode 100644 index 0000000..83090b1 --- /dev/null +++ b/src/adapters/storage.svelte.ts @@ -0,0 +1,18 @@ +import type { SyncStorage } from "@/shared"; + +export interface StorageState { + value: T; +} + +export function reactive(storage: SyncStorage): StorageState { + let value = $state(storage.load()); + return { + get value() { + return value; + }, + set value(newValue) { + value = newValue; + storage.save(newValue); + }, + }; +} diff --git a/src/components/editor-testing-panel.svelte b/src/components/editor-testing-panel.svelte deleted file mode 100644 index 67fea38..0000000 --- a/src/components/editor-testing-panel.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/src/components/editor.svelte b/src/components/editor.svelte deleted file mode 100644 index c108038..0000000 --- a/src/components/editor.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - -
- { - start = { x: e.clientX, y: e.clientY } - info = ed.getLayoutInfo(); - }} - onMove={(e) => { - width = normalizeWidth(start.x - e.clientX + info.width) - }} - onMoveEnd={() => { - widthStorage.save(width) - ed.layout({ - width: width, - height: info.height - }) - }} - /> -
-
- {@render children(lang, model)} - -
-
diff --git a/src/components/editor/editor-surface.svelte b/src/components/editor/editor-surface.svelte new file mode 100644 index 0000000..8a8d895 --- /dev/null +++ b/src/components/editor/editor-surface.svelte @@ -0,0 +1,147 @@ + + +
+ { + start = { x: e.clientX, y: $state.snapshot(width) } + }} + onMove={(e) => { + width = normalizeWidth(start.x - e.clientX + start.y) + ed?.layout({ width, height }, true) + }} + onMoveEnd={() => { + widthStorage.save(width) + }} + /> +
+ {#snippet resizer()} + { + start = { x: $state.snapshot(height), y: e.clientY } + }} + onMove={(e) => { + height = normalizeHeight(start.x - (start.y - e.clientY)) + ed?.layout({ width, height }, true) + }} + /> + {/snippet} + {@render panel({ + resizer, + api + })} +
diff --git a/src/components/editor/editor.svelte b/src/components/editor/editor.svelte new file mode 100644 index 0000000..cf1bf50 --- /dev/null +++ b/src/components/editor/editor.svelte @@ -0,0 +1,84 @@ + + + + {#snippet panel({ resizer, api })} + + {#snippet header()} + + + {/snippet} + + {/snippet} + diff --git a/src/components/editor/lang-select.svelte b/src/components/editor/lang-select.svelte new file mode 100644 index 0000000..1931860 --- /dev/null +++ b/src/components/editor/lang-select.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/components/editor/model.ts b/src/components/editor/model.ts new file mode 100644 index 0000000..0dd01cd --- /dev/null +++ b/src/components/editor/model.ts @@ -0,0 +1,16 @@ +import type { editor } from 'monaco-editor'; + +import { createSyncStorage } from '@/adapters/storage'; +import { reactive } from '@/adapters/storage.svelte' + +export interface SurfaceApi { + editor: editor.IStandaloneCodeEditor | undefined; + width: number; + panelHeight: number; + isPanelCollapsed: boolean; + showPanel(height: number): boolean; + hidePanel(): boolean; + togglePanel(height: number): boolean; +} + +export const vimState = reactive(createSyncStorage(localStorage, 'editor-vim', false)); diff --git a/src/components/editor/resizer.svelte b/src/components/editor/resizer.svelte new file mode 100644 index 0000000..6b93621 --- /dev/null +++ b/src/components/editor/resizer.svelte @@ -0,0 +1,74 @@ + + + + +
diff --git a/src/components/editor/terminal.ts b/src/components/editor/terminal.ts new file mode 100644 index 0000000..dd3995d --- /dev/null +++ b/src/components/editor/terminal.ts @@ -0,0 +1,10 @@ +import type { ITheme } from "@xterm/xterm"; +import type { Theme } from "daisyui"; +import themes from "daisyui/src/theming/themes"; + +export function makeTheme(themeName: Theme): ITheme { + const theme = themes[themeName]; + return { + background: "oklch(23.1012% 0 0 / 1)", + }; +} diff --git a/src/components/editor/testing-panel.svelte b/src/components/editor/testing-panel.svelte new file mode 100644 index 0000000..71f951a --- /dev/null +++ b/src/components/editor/testing-panel.svelte @@ -0,0 +1,215 @@ + + +
+
+ +
+ {#snippet tabButton({ tab, append }: TabButtonProps)} + { + selectedTab = tab + api.showPanel(window.innerHeight/3) + }} + > + {TAB_TITLES[tab]} + {#if append} + {@render append()} + {/if} + + {/snippet} + {#snippet testBadge()} +
= 0} + > + {lastTestId}/{testsData.length} +
+ {/snippet} + {@render tabButton({ tab: Tab.Tests, append: testBadge })} + {@render tabButton({ tab: Tab.Output })} + {@render tabButton({ tab: Tab.Settings })} +
+
+ {@render header()} + +
+
+
+ {#each testsData as testData, i} +
+
+ {#if lastTestId === i} + + {:else if i < lastTestId} + + {:else} + + {/if} + Case {i + 1} +
+
{JSON.stringify(testData.input, null, 2)}
+
+ {/each} +
+
+
+
+
+
+ +
+
+
+ {@render children()} +
+ + diff --git a/src/components/editor/vim-mode.svelte b/src/components/editor/vim-mode.svelte new file mode 100644 index 0000000..0dc8f24 --- /dev/null +++ b/src/components/editor/vim-mode.svelte @@ -0,0 +1,25 @@ + + +
diff --git a/src/components/resizer.svelte b/src/components/resizer.svelte deleted file mode 100644 index 004d943..0000000 --- a/src/components/resizer.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -
diff --git a/src/containers/editor-provider.astro b/src/containers/editor-provider.astro index 190fbbb..4825644 100644 --- a/src/containers/editor-provider.astro +++ b/src/containers/editor-provider.astro @@ -8,6 +8,7 @@ switch (label) { case "editorWorkerService": return new EditorWorker(); + case "javascript": case "typescript": return new TsWorker(); default: @@ -17,4 +18,4 @@ }; - \ No newline at end of file + diff --git a/src/content/design-patterns/factory/editor.svelte b/src/content/design-patterns/factory/editor.svelte index 89a6a77..e01475e 100644 --- a/src/content/design-patterns/factory/editor.svelte +++ b/src/content/design-patterns/factory/editor.svelte @@ -1,10 +1,7 @@ { model.setValue(await INITIAL_VALUES[lang]) - langStorage.save(lang) }} -> - {#snippet children(lang, model)} - - {/snippet} - +/> diff --git a/src/content/design-patterns/factory/js/test-runners.ts b/src/content/design-patterns/factory/js/test-runners.ts index 3e69834..4bec594 100644 --- a/src/content/design-patterns/factory/js/test-runners.ts +++ b/src/content/design-patterns/factory/js/test-runners.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@/lib/logger'; +import type { TestRunnerConfig } from '@/lib/testing'; import { TsTestRunner, JsTestRunner } from "@/lib/testing/js"; import { type Input, type Output } from "../tests-data"; @@ -14,8 +16,8 @@ class SimpleJsTestRunner extends JsTestRunner { } } -export const jsTestRunnerFactory = async (code: string) => - new SimpleJsTestRunner(code); +export const jsTestRunnerFactory = async ({ code, out }: TestRunnerConfig) => + new SimpleJsTestRunner(createLogger(out), code); class SimpleTsTestRunner extends TsTestRunner { async executeTest(m: TestingModule, input: Input): Promise { @@ -23,5 +25,5 @@ class SimpleTsTestRunner extends TsTestRunner { } } -export const tsTestRunnerFactory = async (code: string) => - new SimpleTsTestRunner(code); +export const tsTestRunnerFactory = async ({ code, out }: TestRunnerConfig) => + new SimpleTsTestRunner(createLogger(out), code); diff --git a/src/content/design-patterns/factory/php/test-runners.ts b/src/content/design-patterns/factory/php/test-runners.ts index f86b40e..f0365b6 100644 --- a/src/content/design-patterns/factory/php/test-runners.ts +++ b/src/content/design-patterns/factory/php/test-runners.ts @@ -1,3 +1,4 @@ +import type { TestRunnerConfig } from "@/lib/testing"; import { FailSafePHP, PHPTestRunner, @@ -26,5 +27,5 @@ class SimpleTestRunner extends PHPTestRunner { } } -export const testRunnerFactory = async (code: string) => - new SimpleTestRunner(new FailSafePHP(phpRuntimeFactory), code); +export const testRunnerFactory = async ({ code, out }: TestRunnerConfig) => + new SimpleTestRunner(out, new FailSafePHP(phpRuntimeFactory), code); diff --git a/src/content/design-patterns/factory/python/test-runners.ts b/src/content/design-patterns/factory/python/test-runners.ts index a8199ea..2760d5e 100644 --- a/src/content/design-patterns/factory/python/test-runners.ts +++ b/src/content/design-patterns/factory/python/test-runners.ts @@ -1,3 +1,4 @@ +import type { TestRunnerConfig } from '@/lib/testing'; import { PyTestRunner, pyRuntimeFactory } from "@/lib/testing/python"; import { type Input, type Output } from "../tests-data"; @@ -8,5 +9,5 @@ class SimpleTestRunner extends PyTestRunner { } } -export const testRunnerFactory = async (code: string) => - new SimpleTestRunner(await pyRuntimeFactory(), code); +export const testRunnerFactory = async ({ code, out }: TestRunnerConfig) => + new SimpleTestRunner(await pyRuntimeFactory(out), code); diff --git a/src/lib/json.ts b/src/lib/json.ts new file mode 100644 index 0000000..49093ca --- /dev/null +++ b/src/lib/json.ts @@ -0,0 +1,16 @@ +export function stringify(obj: any, spaces?: number | string) { + const seen = new WeakSet(); + return JSON.stringify( + obj, + (_, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + } + return value; + }, + spaces + ); +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..edf713e --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,91 @@ +import { stringify } from '@/lib/json' + +export interface Writer { + write(text: string): void; + writeln(text: string): void; +} + +export interface Logger { + debug(text: string): void; + info(text: string): void; + warn(text: string): void; + error(text: string): void; +} + +// ANSI color codes for log levels +const colors = { + debug: '\x1b[32m', // Green + info: '\x1b[34m', // Blue + warn: '\x1b[33m', // Yellow + error: '\x1b[31m', // Red + reset: '\x1b[0m' // Reset to default color +}; + +export function createLogger(writer: Writer): Logger { + return { + debug(text) { + writer.writeln(`${colors.debug}[DEBUG]${colors.reset} ${text}`); + }, + info(text) { + writer.writeln(`${colors.info}[INFO]${colors.reset} ${text}`); + }, + warn(text) { + writer.writeln(`${colors.warn}[WARN]${colors.reset} ${text}`); + }, + error(text) { + writer.writeln(`${colors.error}[ERROR]${colors.reset} ${text}`); + }, + }; +} + +type ConsoleLogMethod = + | "log" + | "info" + | "warn" + | "error" + | "debug" + | "trace" + | "assert" + | "dir"; + +const CONSOLE_LOGGER_METHOD: Record = { + dir: "debug", + trace: "debug", + log: "debug", + debug: "debug", + info: "info", + warn: "warn", + error: "error", + assert: "error", +}; + +const safeStringify = (value: any) => stringify(value, 2); + +export function redirect(originalConsole: Console, logger: Logger): Console { + return new Proxy(originalConsole, { + get(target, p, receiver) { + if (p in CONSOLE_LOGGER_METHOD) { + const method = CONSOLE_LOGGER_METHOD[p as ConsoleLogMethod]; + if (p === "assert") { + return (condition: any, ...args: any[]) => { + if (condition) { + return; + } + const text = args.map(safeStringify).join(" "); + logger[method](text); + }; + } + if (p === "dir") { + return (arg: any) => { + logger[method](safeStringify(arg)); + }; + } + return (...args: any[]) => { + const text = args.map(safeStringify).join(" "); + logger[method](text); + }; + } + return Reflect.get(target, p, receiver); + }, + }); +} diff --git a/src/lib/testing/index.ts b/src/lib/testing/index.ts index 9933e27..8d71f09 100644 --- a/src/lib/testing/index.ts +++ b/src/lib/testing/index.ts @@ -1,2 +1,2 @@ -export * from "./testing"; +export * from "./model"; export * from "./languages"; diff --git a/src/lib/testing/js/js-test-runner.ts b/src/lib/testing/js/js-test-runner.ts index bac715e..273a823 100644 --- a/src/lib/testing/js/js-test-runner.ts +++ b/src/lib/testing/js/js-test-runner.ts @@ -1,7 +1,15 @@ -import type { TestRunner } from "../testing"; +import { redirect, type Logger } from "@/lib/logger"; + +import type { TestRunner } from "../model"; export abstract class JsTestRunner implements TestRunner { - constructor(protected readonly code: string) {} + private readonly patchedConsole: Console; + constructor( + protected readonly logger: Logger, + protected readonly code: string + ) { + this.patchedConsole = redirect(globalThis.console, logger); + } protected transformCode(code: string) { return `data:text/javascript;base64,${btoa(code)}`; @@ -11,8 +19,14 @@ export abstract class JsTestRunner implements TestRunner { async run(input: I): Promise { const transformedCode = this.transformCode(this.code); - const m = await import(/* @vite-ignore */ transformedCode); - return this.executeTest(m, input); + const originalConsole = globalThis.console; + globalThis.console = this.patchedConsole + try { + const m = await import(/* @vite-ignore */ transformedCode); + return this.executeTest(m, input); + } finally { + globalThis.console = originalConsole; + } } [Symbol.dispose](): void {} diff --git a/src/lib/testing/testing.ts b/src/lib/testing/model.ts similarity index 59% rename from src/lib/testing/testing.ts rename to src/lib/testing/model.ts index 96ca4ad..86e57e6 100644 --- a/src/lib/testing/testing.ts +++ b/src/lib/testing/model.ts @@ -1,5 +1,7 @@ import deepEqual from "fast-deep-equal"; +import type { Logger, Writer } from '@/lib/logger' + export interface TestData { input: I; output: O; @@ -18,27 +20,33 @@ export interface TestRunner extends Disposable { run: (input: I) => Promise; } +export interface TestRunnerConfig { + code: string; + out: Writer +} + export type TestRunnerFactory = ( - code: string + config: TestRunnerConfig ) => Promise>; -export async function runTest( - testCase: TestRunner, - testData: TestData[] +export async function runTests( + log: Logger, + testRunner: TestRunner, + testsData: TestData[], ) { let i = 0; - for (; i < testData.length; i++) { - const data = testData[i]; + for (; i < testsData.length; i++) { + const data = testsData[i]; try { - const result = await testCase.run(data.input); + const result = await testRunner.run(data.input); if (!deepEqual(result, data.output)) { - console.error( + log.error( `Test case failed, expected "${data.output}", but got "${result}"` ); return i; } } catch (err) { - console.error(`Test case failed: ${err}`); + log.error(`Test case failed: ${err}`); return i; } } diff --git a/src/lib/testing/php/php-test-runner.ts b/src/lib/testing/php/php-test-runner.ts index ba531d0..8585a17 100644 --- a/src/lib/testing/php/php-test-runner.ts +++ b/src/lib/testing/php/php-test-runner.ts @@ -1,11 +1,17 @@ import type { WebPHP } from "@php-wasm/web"; -import type { TestRunner } from "../testing"; +import type { Writer } from '@/lib/logger'; + +import type { TestRunner } from "../model"; export abstract class PHPTestRunner implements TestRunner { private result?: O; - constructor(protected readonly php: WebPHP, protected readonly code: string) { + constructor( + protected writer: Writer, + protected readonly php: WebPHP, + protected readonly code: string, + ) { php.onMessage(this.handleResult.bind(this)); } @@ -28,6 +34,10 @@ export abstract class PHPTestRunner implements TestRunner { async run(input: I): Promise { const code = this.transformCode(input); const response = await this.php.run({ code }); + const text = response.text; + if (text) { + this.writer.writeln(text); + } if (response.errors) { throw new Error(response.errors); } diff --git a/src/lib/testing/python/py-runtime-factory.ts b/src/lib/testing/python/py-runtime-factory.ts index 3049531..6016f0b 100644 --- a/src/lib/testing/python/py-runtime-factory.ts +++ b/src/lib/testing/python/py-runtime-factory.ts @@ -1,8 +1,11 @@ import { loadPyodide } from "pyodide"; -export const pyRuntimeFactory = () => +import type { Writer } from '@/lib/logger'; + +export const pyRuntimeFactory = (writer: Writer) => loadPyodide({ indexURL: import.meta.env.DEV ? undefined : `${import.meta.env.BASE_URL}/assets/pyodide`, + stdout: writer.writeln.bind(writer), }); diff --git a/src/lib/testing/python/py-test-runner.ts b/src/lib/testing/python/py-test-runner.ts index 34b1a41..8d4f846 100644 --- a/src/lib/testing/python/py-test-runner.ts +++ b/src/lib/testing/python/py-test-runner.ts @@ -1,7 +1,7 @@ import { loadPyodide } from "pyodide"; import type { PyProxy } from "pyodide/ffi"; -import type { TestRunner } from "../testing"; +import type { TestRunner } from "../model"; function isPyProxy(obj: any): obj is PyProxy { return typeof obj === "object" && obj; diff --git a/src/monaco-vim.d.ts b/src/monaco-vim.d.ts new file mode 100644 index 0000000..0d8bf75 --- /dev/null +++ b/src/monaco-vim.d.ts @@ -0,0 +1,85 @@ +// Copied from `typehero` project licensed under AGPL-3.0 +// https://github.com/typehero/typehero/blob/main/packages/monaco/monaco-vim.d.ts + +declare module 'monaco-vim' { + import type * as monaco from 'monaco-editor'; + + export function initVimMode( + editor: monaco.editor.IStandaloneCodeEditor, + statusbarNode?: Element | null, + ): CMAdapter; + + type VimModes = 'insert' | 'normal' | 'visual'; + + class CMAdapter { + /** removes the attached vim bindings */ + dispose(): void; + + attached: boolean; + + editor: monaco.editor.IStandaloneCodeEditor; + + statusBar: { + clear(): void; + node: HTMLElement; + }; + + /** @see https://codemirror.net/5/doc/manual.html#vimapi */ + static Vim: { + map(lhs: string, rhs: string, ctx: VimModes): void; + unmap(lhs: string, ctx: VimModes | false): boolean; + noremap(lhs: string, rhs: string, ctx: VimModes): void; + + mapCommand( + keys: string, + type: 'action', + name: string, + args?: Record, + extra?: Record, + ): void; + + defineAction( + name: string, + fn: ( + ctx: CMAdapter, + // TODO: Document other args + ...args: [unknown, unknown] + ) => void, + ): void; + + defineEx( + name: string, + prefix: string | undefined, + fn: ( + ctx: CMAdapter, + data: { + commandName: string; + input: string; + } & ( + | { + argString: string; + args: [string, ...string[]]; + } + | { argString?: never; args?: never } + ) & + ({ line: undefined } | { line: number; lineEnd: number }), + ) => void, + ): void; + + /** clears user created mappings */ + mapclear(ctx?: VimModes): void; + + /** call this before `VimMode.Vim.handleKey` */ + maybeInitVimState_(cma: CMAdapter): void; + + /** + * calls an ex command, equivalent to `:` in vim + * + * *If it fails with `vim is null` call `VimMode.Vim.maybeInitVimState_` first* + */ + handleEx(cma: CMAdapter, ex: string): void; + }; + } + + export { CMAdapter as VimMode, type CMAdapter }; +} diff --git a/src/pages/example.astro b/src/pages/example.astro index 229150d..1611f0b 100644 --- a/src/pages/example.astro +++ b/src/pages/example.astro @@ -22,10 +22,11 @@ import { Content as Description } from "@/content/design-patterns/factory/descri
  • Design Patterns
  • - +
    + + + +