[ty] Embeddable ty playground

This commit is contained in:
David Peter 2025-12-04 14:19:16 +01:00
parent fdcb5a7e73
commit c3d225fe61
12 changed files with 1260 additions and 1 deletions

View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"workspaces": [
"ty",
"ty-embed",
"ruff",
"shared"
],
@ -6081,6 +6082,10 @@
"resolved": "ty/ty_wasm",
"link": true
},
"node_modules/ty-embed": {
"resolved": "ty-embed",
"link": true
},
"node_modules/ty-playground": {
"resolved": "ty",
"link": true
@ -6608,6 +6613,22 @@
"vite-plugin-static-copy": "^3.0.0"
}
},
"ty-embed": {
"version": "0.0.0",
"dependencies": {
"monaco-editor": "^0.54.0",
"ty_wasm": "file:ty_wasm"
},
"devDependencies": {
"typescript": "^5.8.2",
"vite": "^7.0.0"
}
},
"ty-embed/ty_wasm": {
"version": "0.0.0",
"extraneous": true,
"license": "MIT"
},
"ty/ty_wasm": {
"version": "0.0.0",
"license": "MIT"

View File

@ -9,11 +9,12 @@
"dev:build": "npm run dev:build --workspace ty-playground && npm run dev:build --workspace ruff-playground",
"fmt": "prettier --cache -w .",
"fmt:check": "prettier --cache --check .",
"lint": "eslint --cache --ext .ts,.tsx ruff/src ty/src",
"lint": "eslint --cache --ext .ts,.tsx ruff/src ty/src ty-embed/src",
"tsc": "tsc"
},
"workspaces": [
"ty",
"ty-embed",
"ruff",
"shared"
],

5
playground/ty-embed/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
ty_wasm
.DS_Store
*.log

View File

@ -0,0 +1,3 @@
# ty Embeddable Editor
A simplified, embeddable version of the ty playground that can be used in documentation pages. This allows you to create multiple interactive Python type-checking editors on a single webpage.

View File

@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ty Type Checker - Interactive Example</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #1a1a1a;
padding-bottom: 12px;
margin-bottom: 20px;
}
h2 {
color: #2a2a2a;
margin-top: 30px;
margin-bottom: 16px;
font-size: 1.5em;
}
p {
color: #555;
margin-bottom: 16px;
}
.intro {
font-size: 1.1em;
color: #666;
margin-bottom: 40px;
}
.editor-container {
margin: 24px 0 48px 0;
}
</style>
</head>
<body>
<h1>
ty Type Checker
</h1>
<p class="intro">
This interactive editor provides real-time type checking.
Hover over symbols to see type information, Ctrl+click to jump to definitions,
and click on errors to see quick fix suggestions.
</p>
<h2>TypedDict Keys</h2>
<p>
The code below has a typo in the dictionary key. Click on the error
and use the quick fix (lightbulb icon or Ctrl+.) to automatically fix it:
</p>
<div id="editor" class="editor-container"></div>
<script type="module">
// Development version - imports from source
import { createTyEditor } from "./src/index.ts";
const editor = createTyEditor({
container: "#editor",
id: "typeddict-example",
theme: "light",
height: "360px",
initialCode: `from typing import TypedDict
class Person(TypedDict):
name: str
age: int | None
def greet(person: Person):
print("Hello", person["name"])
`,
});
// Expose for debugging
window.tyEditor = editor;
</script>
</body>
</html>

View File

@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ty Type Checker - Interactive Example</title>
<link rel="stylesheet" href="./dist/ty-embed.css" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #1a1a1a;
padding-bottom: 12px;
margin-bottom: 20px;
}
h2 {
color: #2a2a2a;
margin-top: 30px;
margin-bottom: 16px;
font-size: 1.5em;
}
p {
color: #555;
margin-bottom: 16px;
}
.intro {
font-size: 1.1em;
color: #666;
margin-bottom: 40px;
}
.editor-container {
margin: 24px 0 48px 0;
}
</style>
</head>
<body>
<h1>
ty Type Checker
</h1>
<p class="intro">
This interactive editor provides real-time type checking.
Hover over symbols to see type information, Ctrl+click to jump to definitions,
and click on errors to see quick fix suggestions.
</p>
<h2>TypedDict Keys</h2>
<p>
The code below has a typo in the dictionary key. Click on the error
and use the quick fix (lightbulb icon or Ctrl+.) to automatically fix it:
</p>
<div id="editor" class="editor-container"></div>
<!-- Load the ty-embed library -->
<script type="module">
import { createTyEditor } from "./dist/ty-embed.es.js";
createTyEditor({
container: "#editor",
id: "typeddict-example",
theme: "light",
height: "360px",
initialCode: `from typing import TypedDict
class Person(TypedDict):
name: str
age: int | None
def greet(person: Person):
print("Hello", person["name"])
`,
});
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
{
"name": "ty-embed",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"prebuild": "npm run build:wasm",
"build": "vite build",
"build:wasm": "wasm-pack build ../../crates/ty_wasm --target web --out-dir ../../playground/ty-embed/ty_wasm",
"dev:wasm": "wasm-pack build ../../crates/ty_wasm --dev --target web --out-dir ../../playground/ty-embed/ty_wasm",
"predev:build": "npm run dev:wasm",
"dev:build": "vite build",
"prestart": "npm run dev:wasm",
"start": "vite"
},
"dependencies": {
"monaco-editor": "^0.54.0",
"ty_wasm": "file:ty_wasm"
},
"devDependencies": {
"typescript": "^5.8.2",
"vite": "^7.0.0",
"vite-plugin-static-copy": "^2.2.0"
}
}

View File

@ -0,0 +1,843 @@
import * as monaco from "monaco-editor";
import {
FileHandle,
PositionEncoding,
Workspace,
Range as TyRange,
Severity,
Position as TyPosition,
CompletionKind,
LocationLink,
TextEdit,
InlayHintKind,
Diagnostic as TyDiagnostic,
} from "ty_wasm";
// Ayu theme colors from the ty playground
const RADIATE = "#d7ff64";
const ROCK = "#78876e";
const COSMIC = "#de5fe9";
const SUN = "#ffac2f";
const ELECTRON = "#46ebe1";
const CONSTELLATION = "#5f6de9";
const STARLIGHT = "#f4f4f1";
const PROTON = "#f6afbc";
const SUPERNOVA = "#f1aff6";
const ASTEROID = "#e3cee3";
let themesInitialized = false;
function defineAyuThemes() {
if (themesInitialized) return;
themesInitialized = true;
// Ayu Light theme
monaco.editor.defineTheme("Ayu-Light", {
inherit: false,
base: "vs",
colors: {
"editor.background": "#f8f9fa",
"editor.foreground": "#5c6166",
"editorLineNumber.foreground": "#8a919966",
"editorLineNumber.activeForeground": "#8a9199cc",
"editorCursor.foreground": "#ffaa33",
"editor.selectionBackground": "#035bd626",
"editor.lineHighlightBackground": "#8a91991a",
"editorIndentGuide.background": "#8a91992e",
"editorIndentGuide.activeBackground": "#8a919959",
"editorError.foreground": "#e65050",
"editorWarning.foreground": "#ffaa33",
"editorWidget.background": "#f3f4f5",
"editorWidget.border": "#6b7d8f1f",
"editorHoverWidget.background": "#f3f4f5",
"editorHoverWidget.border": "#6b7d8f1f",
"editorSuggestWidget.background": "#f3f4f5",
"editorSuggestWidget.border": "#6b7d8f1f",
"editorSuggestWidget.highlightForeground": "#ffaa33",
"editorSuggestWidget.selectedBackground": "#56728f1f",
},
rules: [
{ fontStyle: "italic", foreground: "#787b8099", token: "comment" },
{ foreground: COSMIC, token: "keyword" },
{ foreground: COSMIC, token: "builtinConstant" },
{ foreground: CONSTELLATION, token: "number" },
{ foreground: ROCK, token: "tag" },
{ foreground: ROCK, token: "string" },
{ foreground: SUN, token: "method" },
{ foreground: SUN, token: "function" },
{ foreground: SUN, token: "decorator" },
],
encodedTokensColors: [],
});
// Ayu Dark theme
monaco.editor.defineTheme("Ayu-Dark", {
inherit: false,
base: "vs-dark",
colors: {
"editor.background": "#0b0e14",
"editor.foreground": "#bfbdb6",
"editorLineNumber.foreground": "#6c738099",
"editorLineNumber.activeForeground": "#6c7380e6",
"editorCursor.foreground": "#e6b450",
"editor.selectionBackground": "#409fff4d",
"editor.lineHighlightBackground": "#131721",
"editorIndentGuide.background": "#6c738033",
"editorIndentGuide.activeBackground": "#6c738080",
"editorError.foreground": "#d95757",
"editorWarning.foreground": "#e6b450",
"editorWidget.background": "#0f131a",
"editorWidget.border": "#11151c",
"editorHoverWidget.background": "#0f131a",
"editorHoverWidget.border": "#11151c",
"editorSuggestWidget.background": "#0f131a",
"editorSuggestWidget.border": "#11151c",
"editorSuggestWidget.highlightForeground": "#e6b450",
"editorSuggestWidget.selectedBackground": "#47526640",
},
rules: [
{ fontStyle: "italic", foreground: "#acb6bf8c", token: "comment" },
{ foreground: ELECTRON, token: "string" },
{ foreground: CONSTELLATION, token: "number" },
{ foreground: STARLIGHT, token: "identifier" },
{ foreground: RADIATE, token: "keyword" },
{ foreground: RADIATE, token: "builtinConstant" },
{ foreground: PROTON, token: "tag" },
{ foreground: ASTEROID, token: "delimiter" },
{ foreground: SUPERNOVA, token: "class" },
{ foreground: STARLIGHT, token: "variable" },
{ foreground: STARLIGHT, token: "parameter" },
{ foreground: SUN, token: "method" },
{ foreground: SUN, token: "function" },
{ foreground: SUN, token: "decorator" },
],
encodedTokensColors: [],
});
}
export interface EditorOptions {
initialCode?: string;
theme?: "light" | "dark";
fileName?: string;
settings?: Record<string, any>;
height?: string;
showDiagnostics?: boolean;
id?: string;
}
interface Diagnostic {
id: string;
message: string;
severity: Severity;
range: TyRange | null;
raw: TyDiagnostic;
}
const DEFAULT_SETTINGS = {
environment: {
"python-version": "3.14",
},
rules: {
"undefined-reveal": "ignore",
},
};
export class EmbeddableEditor {
private container: HTMLElement;
private options: Required<EditorOptions>;
private editor: monaco.editor.IStandaloneCodeEditor | null = null;
private workspace: Workspace | null = null;
private fileHandle: FileHandle | null = null;
private languageServer: LanguageServer | null = null;
private diagnosticsContainer: HTMLElement | null = null;
private editorContainer: HTMLElement | null = null;
private errorContainer: HTMLElement | null = null;
private checkTimeoutId: number | null = null;
constructor(container: HTMLElement | string, options: EditorOptions) {
const element =
typeof container === "string"
? document.querySelector(container)
: container;
if (!element) {
throw new Error(`Container not found: ${container}`);
}
this.container = element as HTMLElement;
this.options = {
initialCode: options.initialCode ?? "",
theme: options.theme ?? "light",
fileName: options.fileName ?? "main.py",
settings: options.settings ?? DEFAULT_SETTINGS,
height: options.height ?? "400px",
showDiagnostics: options.showDiagnostics ?? true,
id: options.id ?? `editor-${Date.now()}`,
};
this.init();
}
private async init() {
try {
// Create container structure
this.createContainerStructure();
// Initialize ty workspace
const ty = await import("ty_wasm");
await ty.default();
this.workspace = new Workspace("/", PositionEncoding.Utf16, {});
this.workspace.updateOptions(this.options.settings);
this.fileHandle = this.workspace.openFile(
this.options.fileName,
this.options.initialCode,
);
// Initialize Monaco editor
if (this.editorContainer) {
// Define Ayu themes before creating the editor
defineAyuThemes();
// Create model with URI matching the workspace file path
const modelUri = monaco.Uri.parse(this.options.fileName);
const model = monaco.editor.createModel(
this.options.initialCode,
"python",
modelUri,
);
this.editor = monaco.editor.create(this.editorContainer, {
model: model,
theme: this.options.theme === "light" ? "Ayu-Light" : "Ayu-Dark",
minimap: { enabled: false },
fontSize: 14,
scrollBeyondLastLine: false,
roundedSelection: false,
contextmenu: true,
automaticLayout: true,
fixedOverflowWidgets: true,
});
// Setup language server features
this.languageServer = new LanguageServer(
this.workspace,
this.fileHandle,
);
// Listen to content changes
this.editor.onDidChangeModelContent(() => {
this.onContentChange();
});
// Initial check
this.checkFile();
}
} catch (err) {
this.showError(this.formatError(err));
}
}
private createContainerStructure() {
const isLight = this.options.theme === "light";
this.container.style.height = this.options.height;
this.container.style.display = "flex";
this.container.style.flexDirection = "column";
this.container.style.border = isLight
? "1px solid #6b7d8f1f"
: "1px solid #11151c";
this.container.style.borderRadius = "4px";
this.container.style.overflow = "hidden";
this.container.style.fontFamily =
'Roboto Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
// Editor container
this.editorContainer = document.createElement("div");
this.editorContainer.style.height = this.options.showDiagnostics
? `calc(${this.options.height} - 120px)`
: this.options.height;
this.container.appendChild(this.editorContainer);
// Diagnostics container
if (this.options.showDiagnostics) {
this.diagnosticsContainer = document.createElement("div");
this.diagnosticsContainer.style.height = "120px";
this.diagnosticsContainer.style.overflow = "auto";
this.diagnosticsContainer.style.borderTop = isLight
? "1px solid #6b7d8f1f"
: "1px solid #11151c";
this.diagnosticsContainer.style.backgroundColor = isLight
? "#f8f9fa"
: "#0b0e14";
this.diagnosticsContainer.style.color = isLight ? "#5c6166" : "#bfbdb6";
this.diagnosticsContainer.style.padding = "8px";
this.diagnosticsContainer.style.fontSize = "13px";
this.container.appendChild(this.diagnosticsContainer);
}
}
private onContentChange() {
// Debounce both workspace update and type checking
if (this.checkTimeoutId !== null) {
window.clearTimeout(this.checkTimeoutId);
}
this.checkTimeoutId = window.setTimeout(() => {
const content = this.editor?.getValue() ?? "";
if (this.workspace && this.fileHandle) {
try {
this.workspace.updateFile(this.fileHandle, content);
} catch (err) {
console.error("Error updating file:", err);
}
}
this.checkFile();
}, 150);
}
private checkFile() {
if (!this.workspace || !this.fileHandle || !this.editor) {
return;
}
try {
const diagnostics = this.workspace.checkFile(this.fileHandle);
const mapped: Diagnostic[] = diagnostics.map((diagnostic) => ({
id: diagnostic.id(),
message: diagnostic.message(),
severity: diagnostic.severity(),
range: diagnostic.toRange(this.workspace!) ?? null,
raw: diagnostic,
}));
this.updateDiagnostics(mapped);
this.hideError();
} catch (err) {
console.error("Error checking file:", err);
this.showError(this.formatError(err));
}
}
private updateDiagnostics(diagnostics: Diagnostic[]) {
// Update language server diagnostics for code actions
if (this.languageServer) {
this.languageServer.updateDiagnostics(diagnostics);
}
// Update Monaco markers
if (this.editor) {
const model = this.editor.getModel();
if (model) {
monaco.editor.setModelMarkers(
model,
"owner",
diagnostics.map((diagnostic) => {
const range = diagnostic.range;
return {
code: diagnostic.id,
startLineNumber: range?.start?.line ?? 1,
startColumn: range?.start?.column ?? 1,
endLineNumber: range?.end?.line ?? 1,
endColumn: range?.end?.column ?? 1,
message: diagnostic.message,
severity: this.mapSeverity(diagnostic.severity),
tags: [],
};
}),
);
}
}
// Update diagnostics panel
if (this.diagnosticsContainer) {
const isLight = this.options.theme === "light";
const mutedColor = isLight ? "#8a9199" : "#565b66";
this.diagnosticsContainer.innerHTML = "";
if (diagnostics.length > 0) {
const list = document.createElement("ul");
list.style.margin = "0";
list.style.padding = "0";
list.style.listStyle = "none";
diagnostics.forEach((diagnostic) => {
const item = this.createDiagnosticItem(diagnostic, mutedColor);
list.appendChild(item);
});
this.diagnosticsContainer.appendChild(list);
}
}
}
private createDiagnosticItem(
diagnostic: Diagnostic,
mutedColor: string,
): HTMLElement {
const startLine = diagnostic.range?.start?.line ?? 1;
const startColumn = diagnostic.range?.start?.column ?? 1;
const isLight = this.options.theme === "light";
// Error: red, Warning: yellow/orange
const severityColor =
diagnostic.severity === Severity.Error
? isLight
? "#e65050"
: "#d95757"
: diagnostic.severity === Severity.Warning
? isLight
? "#f2ae49"
: "#e6b450"
: mutedColor;
const item = document.createElement("li");
item.style.marginBottom = "8px";
item.style.paddingLeft = "8px";
item.style.borderLeft = `3px solid ${severityColor}`;
const button = document.createElement("button");
button.style.all = "unset";
button.style.width = "100%";
button.style.textAlign = "left";
button.style.cursor = "pointer";
button.style.userSelect = "text";
button.innerHTML = `
<span style="color: ${severityColor}; font-weight: 500;">${diagnostic.id}</span>
<span style="color: ${mutedColor}; margin-left: 8px;">${startLine}:${startColumn}</span>
<div style="margin-top: 2px;">${diagnostic.message}</div>
`;
button.addEventListener("click", () => {
if (diagnostic.range && this.editor) {
const range = diagnostic.range;
this.editor.revealRange({
startLineNumber: range.start.line,
startColumn: range.start.column,
endLineNumber: range.end.line,
endColumn: range.end.column,
});
this.editor.setSelection({
startLineNumber: range.start.line,
startColumn: range.start.column,
endLineNumber: range.end.line,
endColumn: range.end.column,
});
this.editor.focus();
}
});
item.appendChild(button);
return item;
}
private showError(message: string) {
if (!this.errorContainer) {
this.errorContainer = document.createElement("div");
this.errorContainer.style.position = "absolute";
this.errorContainer.style.bottom = "10px";
this.errorContainer.style.left = "10px";
this.errorContainer.style.right = "10px";
this.errorContainer.style.padding = "12px";
this.errorContainer.style.backgroundColor = "#ff4444";
this.errorContainer.style.color = "#fff";
this.errorContainer.style.borderRadius = "4px";
this.container.style.position = "relative";
this.container.appendChild(this.errorContainer);
}
this.errorContainer.textContent = message;
this.errorContainer.style.display = "block";
}
private hideError() {
if (this.errorContainer) {
this.errorContainer.style.display = "none";
}
}
private formatError(error: unknown): string {
const message = error instanceof Error ? error.message : `${error}`;
return message.startsWith("Error: ")
? message.slice("Error: ".length)
: message;
}
private mapSeverity(severity: Severity): monaco.MarkerSeverity {
switch (severity) {
case Severity.Info:
return monaco.MarkerSeverity.Info;
case Severity.Warning:
return monaco.MarkerSeverity.Warning;
case Severity.Error:
return monaco.MarkerSeverity.Error;
case Severity.Fatal:
return monaco.MarkerSeverity.Error;
}
}
dispose() {
this.languageServer?.dispose();
this.editor?.dispose();
if (this.workspace && this.fileHandle) {
try {
this.workspace.closeFile(this.fileHandle);
} catch (err) {
console.warn("Error closing file:", err);
}
}
if (this.checkTimeoutId !== null) {
window.clearTimeout(this.checkTimeoutId);
}
}
}
class LanguageServer
implements
monaco.languages.DefinitionProvider,
monaco.languages.HoverProvider,
monaco.languages.CompletionItemProvider,
monaco.languages.InlayHintsProvider,
monaco.languages.CodeActionProvider
{
private definitionDisposable: monaco.IDisposable;
private hoverDisposable: monaco.IDisposable;
private completionDisposable: monaco.IDisposable;
private inlayHintsDisposable: monaco.IDisposable;
private codeActionDisposable: monaco.IDisposable;
private diagnostics: Diagnostic[] = [];
constructor(
private workspace: Workspace,
private fileHandle: FileHandle,
) {
this.definitionDisposable = monaco.languages.registerDefinitionProvider(
"python",
this,
);
this.hoverDisposable = monaco.languages.registerHoverProvider(
"python",
this,
);
this.completionDisposable =
monaco.languages.registerCompletionItemProvider("python", this);
this.inlayHintsDisposable = monaco.languages.registerInlayHintsProvider(
"python",
this,
);
this.codeActionDisposable = monaco.languages.registerCodeActionProvider(
"python",
this,
);
}
updateDiagnostics(diagnostics: Diagnostic[]) {
this.diagnostics = diagnostics;
}
triggerCharacters = ["."];
provideCompletionItems(
_model: monaco.editor.ITextModel,
position: monaco.Position,
): monaco.languages.ProviderResult<monaco.languages.CompletionList> {
try {
const completions = this.workspace.completions(
this.fileHandle,
new TyPosition(position.lineNumber, position.column),
);
const digitsLength = String(completions.length - 1).length;
return {
suggestions: completions.map((completion, i) => ({
label: {
label: completion.name,
detail:
completion.module_name == null
? undefined
: ` (import ${completion.module_name})`,
description: completion.detail ?? undefined,
},
sortText: String(i).padStart(digitsLength, "0"),
kind:
completion.kind == null
? monaco.languages.CompletionItemKind.Variable
: mapCompletionKind(completion.kind),
insertText: completion.insert_text ?? completion.name,
additionalTextEdits: completion.additional_text_edits?.map(
(edit: TextEdit) => ({
range: tyRangeToMonacoRange(edit.range),
text: edit.new_text,
}),
),
documentation: completion.documentation,
detail: completion.detail,
range: undefined as any,
})),
};
} catch (err) {
console.warn("Error providing completions:", err);
return undefined;
}
}
provideHover(
_model: monaco.editor.ITextModel,
position: monaco.Position,
): monaco.languages.ProviderResult<monaco.languages.Hover> {
try {
const hover = this.workspace.hover(
this.fileHandle,
new TyPosition(position.lineNumber, position.column),
);
if (hover == null) {
return undefined;
}
return {
range: tyRangeToMonacoRange(hover.range),
contents: [{ value: hover.markdown, isTrusted: true }],
};
} catch (err) {
console.warn("Error providing hover:", err);
return undefined;
}
}
provideDefinition(
model: monaco.editor.ITextModel,
position: monaco.Position,
): monaco.languages.ProviderResult<
monaco.languages.Definition | monaco.languages.LocationLink[]
> {
try {
const links = this.workspace.gotoDefinition(
this.fileHandle,
new TyPosition(position.lineNumber, position.column),
);
if (links.length === 0) {
return undefined;
}
const currentUri = model.uri;
const results = links
.filter((link: LocationLink) => {
const linkUri = monaco.Uri.parse(link.path);
return linkUri.path === currentUri.path;
})
.map((link: LocationLink) => ({
uri: currentUri,
range: tyRangeToMonacoRange(link.full_range),
targetSelectionRange:
link.selection_range == null
? undefined
: tyRangeToMonacoRange(link.selection_range),
originSelectionRange:
link.origin_selection_range == null
? undefined
: tyRangeToMonacoRange(link.origin_selection_range),
}));
return results.length > 0 ? results : undefined;
} catch (err) {
console.warn("Error providing definition:", err);
return undefined;
}
}
provideInlayHints(
_model: monaco.editor.ITextModel,
range: monaco.IRange,
): monaco.languages.ProviderResult<monaco.languages.InlayHintList> {
try {
const inlayHints = this.workspace.inlayHints(
this.fileHandle,
monacoRangeToTyRange(range),
);
if (inlayHints.length === 0) {
return undefined;
}
return {
dispose: () => {},
hints: inlayHints.map((hint) => ({
label: hint.label.map((part) => ({
label: part.label,
})),
position: {
lineNumber: hint.position.line,
column: hint.position.column,
},
kind: mapInlayHintKind(hint.kind),
textEdits: hint.text_edits.map((edit: TextEdit) => ({
range: tyRangeToMonacoRange(edit.range),
text: edit.new_text,
})),
})),
};
} catch (err) {
console.warn("Error providing inlay hints:", err);
return undefined;
}
}
provideCodeActions(
model: monaco.editor.ITextModel,
range: monaco.Range,
): monaco.languages.ProviderResult<monaco.languages.CodeActionList> {
const actions: monaco.languages.CodeAction[] = [];
for (const diagnostic of this.diagnostics) {
const diagnosticRange = diagnostic.range;
if (diagnosticRange == null) {
continue;
}
const monacoRange = tyRangeToMonacoRange(diagnosticRange);
if (!monaco.Range.areIntersecting(range, new monaco.Range(
monacoRange.startLineNumber,
monacoRange.startColumn,
monacoRange.endLineNumber,
monacoRange.endColumn,
))) {
continue;
}
try {
const codeActions = this.workspace.codeActions(
this.fileHandle,
diagnostic.raw,
);
if (codeActions == null) {
continue;
}
for (const codeAction of codeActions) {
actions.push({
title: codeAction.title,
kind: "quickfix",
isPreferred: codeAction.preferred,
edit: {
edits: codeAction.edits.map((edit) => ({
resource: model.uri,
textEdit: {
range: tyRangeToMonacoRange(edit.range),
text: edit.new_text,
},
versionId: model.getVersionId(),
})),
},
});
}
} catch (err) {
console.warn("Error getting code actions:", err);
}
}
if (actions.length === 0) {
return undefined;
}
return {
actions,
dispose: () => {},
};
}
dispose() {
this.definitionDisposable.dispose();
this.hoverDisposable.dispose();
this.completionDisposable.dispose();
this.inlayHintsDisposable.dispose();
this.codeActionDisposable.dispose();
}
}
// Helper functions
function tyRangeToMonacoRange(range: TyRange): monaco.IRange {
return {
startLineNumber: range.start.line,
startColumn: range.start.column,
endLineNumber: range.end.line,
endColumn: range.end.column,
};
}
function monacoRangeToTyRange(range: monaco.IRange): TyRange {
return new TyRange(
new TyPosition(range.startLineNumber, range.startColumn),
new TyPosition(range.endLineNumber, range.endColumn),
);
}
function mapInlayHintKind(kind: InlayHintKind): monaco.languages.InlayHintKind {
switch (kind) {
case InlayHintKind.Type:
return monaco.languages.InlayHintKind.Type;
case InlayHintKind.Parameter:
return monaco.languages.InlayHintKind.Parameter;
}
}
function mapCompletionKind(
kind: CompletionKind,
): monaco.languages.CompletionItemKind {
switch (kind) {
case CompletionKind.Text:
return monaco.languages.CompletionItemKind.Text;
case CompletionKind.Method:
return monaco.languages.CompletionItemKind.Method;
case CompletionKind.Function:
return monaco.languages.CompletionItemKind.Function;
case CompletionKind.Constructor:
return monaco.languages.CompletionItemKind.Constructor;
case CompletionKind.Field:
return monaco.languages.CompletionItemKind.Field;
case CompletionKind.Variable:
return monaco.languages.CompletionItemKind.Variable;
case CompletionKind.Class:
return monaco.languages.CompletionItemKind.Class;
case CompletionKind.Interface:
return monaco.languages.CompletionItemKind.Interface;
case CompletionKind.Module:
return monaco.languages.CompletionItemKind.Module;
case CompletionKind.Property:
return monaco.languages.CompletionItemKind.Property;
case CompletionKind.Unit:
return monaco.languages.CompletionItemKind.Unit;
case CompletionKind.Value:
return monaco.languages.CompletionItemKind.Value;
case CompletionKind.Enum:
return monaco.languages.CompletionItemKind.Enum;
case CompletionKind.Keyword:
return monaco.languages.CompletionItemKind.Keyword;
case CompletionKind.Snippet:
return monaco.languages.CompletionItemKind.Snippet;
case CompletionKind.Color:
return monaco.languages.CompletionItemKind.Color;
case CompletionKind.File:
return monaco.languages.CompletionItemKind.File;
case CompletionKind.Reference:
return monaco.languages.CompletionItemKind.Reference;
case CompletionKind.Folder:
return monaco.languages.CompletionItemKind.Folder;
case CompletionKind.EnumMember:
return monaco.languages.CompletionItemKind.EnumMember;
case CompletionKind.Constant:
return monaco.languages.CompletionItemKind.Constant;
case CompletionKind.Struct:
return monaco.languages.CompletionItemKind.Struct;
case CompletionKind.Event:
return monaco.languages.CompletionItemKind.Event;
case CompletionKind.Operator:
return monaco.languages.CompletionItemKind.Operator;
case CompletionKind.TypeParameter:
return monaco.languages.CompletionItemKind.TypeParameter;
}
}

View File

@ -0,0 +1,44 @@
/* Basic reset for the editor container */
.ty-embed-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
box-sizing: border-box;
}
.ty-embed-container *,
.ty-embed-container *::before,
.ty-embed-container *::after {
box-sizing: inherit;
}
/* Loading state */
.ty-embed-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #666;
}
/* Error message */
.ty-embed-error {
padding: 12px;
background-color: #ff4444;
color: #fff;
border-radius: 4px;
margin: 10px;
}
/* Diagnostics panel */
.ty-embed-diagnostics {
font-family: "Monaco", "Menlo", "Ubuntu Mono", "Consolas", "source-code-pro",
monospace;
}
.ty-embed-diagnostic-item {
transition: background-color 0.15s ease;
}
.ty-embed-diagnostic-item:hover {
filter: brightness(0.95);
}

View File

@ -0,0 +1,85 @@
import { EmbeddableEditor, EditorOptions } from "./EmbeddableEditor";
export interface TyEditorOptions extends EditorOptions {
container: HTMLElement | string;
}
/**
* Initialize a ty editor instance in the specified container.
*
* @param options Configuration options for the editor
* @returns A handle to control the editor instance
*
* @example
* ```javascript
* import { createTyEditor } from 'ty-embed';
*
* const editor = createTyEditor({
* container: '#editor',
* initialCode: 'print("Hello, ty!")',
* theme: 'dark',
* height: '500px'
* });
* ```
*/
export function createTyEditor(options: TyEditorOptions) {
const editor = new EmbeddableEditor(options.container, options);
return {
/**
* Unmount and cleanup the editor instance
*/
dispose() {
editor.dispose();
},
};
}
/**
* Initialize multiple ty editor instances at once.
*
* @param selector CSS selector for containers (e.g., '.ty-editor')
* @param defaultOptions Default options to apply to all editors
* @returns Array of editor handles
*
* @example
* ```html
* <div class="ty-editor" data-code="print('Editor 1')"></div>
* <div class="ty-editor" data-code="print('Editor 2')"></div>
*
* <script>
* createTyEditors('.ty-editor', { theme: 'dark' });
* </script>
* ```
*/
export function createTyEditors(
selector: string,
defaultOptions: Partial<EditorOptions> = {},
) {
const containers = document.querySelectorAll(selector);
const editors = [];
for (const container of Array.from(containers)) {
const dataCode = container.getAttribute("data-code");
const dataTheme = container.getAttribute("data-theme");
const dataHeight = container.getAttribute("data-height");
const dataFile = container.getAttribute("data-file");
const options: TyEditorOptions = {
container: container as HTMLElement,
...defaultOptions,
initialCode: dataCode ?? defaultOptions.initialCode,
theme:
(dataTheme as "light" | "dark") ?? defaultOptions.theme ?? "light",
height: dataHeight ?? defaultOptions.height ?? "400px",
fileName: dataFile ?? defaultOptions.fileName,
};
editors.push(createTyEditor(options));
}
return editors;
}
// Re-export types
export type { EditorOptions };

View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationDir": "./dist/types"
},
"include": ["src"]
}

View File

@ -0,0 +1,43 @@
import { defineConfig } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
export default defineConfig({
plugins: [
viteStaticCopy({
targets: [
{
src: ["ty_wasm/*", "!ty_wasm/.gitignore"],
dest: "ty_wasm",
},
],
}),
],
build: {
lib: {
entry: "./src/index.ts",
name: "TyEmbed",
formats: ["es", "umd"],
fileName: (format) => `ty-embed.${format}.js`,
},
rollupOptions: {
external: ["ty_wasm"],
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name === "style.css") return "ty-embed.css";
return assetInfo.name ?? "asset";
},
paths: {
ty_wasm: "./ty_wasm/ty_wasm.js",
},
},
},
copyPublicDir: false,
},
optimizeDeps: {
exclude: ["ty_wasm"],
},
server: {
port: 3001,
open: "/example-dev.html",
},
});