diff --git a/package.json b/package.json index 260a876..edfbca6 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,10 @@ "packageManager": "pnpm@10.4.0", "engines": { "node": ">=20.19.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.0.6" + } } } diff --git a/packages/prototypey/package.json b/packages/prototypey/package.json index 55d1a36..bfc9572 100644 --- a/packages/prototypey/package.json +++ b/packages/prototypey/package.json @@ -16,7 +16,8 @@ "main": "lib/index.js", "exports": { ".": "./lib/index.js", - "./infer": "./lib/infer.js" + "./infer": "./lib/infer.js", + "./lib/lib.d.ts": "./lib/lib.d.ts" }, "files": [ "lib/", diff --git a/packages/prototypey/src/infer.ts b/packages/prototypey/src/infer.ts index 8d7037a..3741133 100644 --- a/packages/prototypey/src/infer.ts +++ b/packages/prototypey/src/infer.ts @@ -130,12 +130,13 @@ type ReplaceRefsInType = * Infers the TypeScript type for a lexicon namespace, returning only the 'main' definition * with all local refs (#user, #post, etc.) resolved to their actual types. */ -export type Infer }> = - Prettify< - "main" extends keyof T["defs"] - ? { $type: T["id"] } & ReplaceRefsInType< - InferType, - { [K in keyof T["defs"]]: InferType } - > - : never - >; +export type Infer< + T extends { json: { id: string; defs: Record } }, +> = Prettify< + "main" extends keyof T["json"]["defs"] + ? { $type: T["json"]["id"] } & ReplaceRefsInType< + InferType, + { [K in keyof T["json"]["defs"]]: InferType } + > + : never +>; diff --git a/packages/prototypey/src/lib.ts b/packages/prototypey/src/lib.ts index 49414d8..91f9b20 100644 --- a/packages/prototypey/src/lib.ts +++ b/packages/prototypey/src/lib.ts @@ -329,7 +329,7 @@ interface SubscriptionOptions { class Namespace { public json: T; - public infer: Infer = null as unknown as Infer; + public infer: Infer<{ json: T }> = null as unknown as Infer<{ json: T }>; constructor(json: T) { this.json = json; diff --git a/packages/site/.gitignore b/packages/site/.gitignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/packages/site/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/site/index.html b/packages/site/index.html new file mode 100644 index 0000000..e17c190 --- /dev/null +++ b/packages/site/index.html @@ -0,0 +1,12 @@ + + + + + + prototypey - Type-safe lexicon inference for ATProto + + +
+ + + diff --git a/packages/site/package.json b/packages/site/package.json new file mode 100644 index 0000000..be20d81 --- /dev/null +++ b/packages/site/package.json @@ -0,0 +1,31 @@ +{ + "name": "@prototypey/site", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "@monaco-editor/react": "^4.6.0", + "monaco-editor": "0.52.0", + "prototypey": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^25.0.1", + "typescript": "5.8.3", + "vite": "^6.0.5", + "vitest": "^3.2.4" + } +} diff --git a/packages/site/src/App.tsx b/packages/site/src/App.tsx new file mode 100644 index 0000000..e21258a --- /dev/null +++ b/packages/site/src/App.tsx @@ -0,0 +1,11 @@ +import { Header } from "./components/Header"; +import { Playground } from "./components/Playground"; + +export function App() { + return ( + <> +
+ + + ); +} diff --git a/packages/site/src/components/Editor.tsx b/packages/site/src/components/Editor.tsx new file mode 100644 index 0000000..dd3270e --- /dev/null +++ b/packages/site/src/components/Editor.tsx @@ -0,0 +1,157 @@ +import MonacoEditor, { useMonaco, loader } from "@monaco-editor/react"; +import { useEffect, useState } from "react"; +import * as monaco from "monaco-editor"; +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; + +// Configure Monaco Environment for web workers +if (typeof self !== "undefined") { + self.MonacoEnvironment = { + getWorker(_: string, label: string) { + if (label === "json") { + return new jsonWorker(); + } + if (label === "typescript" || label === "javascript") { + return new tsWorker(); + } + return new editorWorker(); + }, + }; +} + +// Configure loader to use local monaco-editor 0.52.0 instead of CDN 0.54.0 +if (loader?.config) { + loader.config({ monaco }); +} + +interface EditorProps { + value: string; + onChange: (value: string) => void; + onReady?: () => void; +} + +export function Editor({ value, onChange, onReady }: EditorProps) { + const [isReady, setIsReady] = useState(false); + const monaco = useMonaco(); + + useEffect(() => { + if (!monaco) return; + + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.ES2020, + allowNonTsExtensions: true, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + module: monaco.languages.typescript.ModuleKind.ESNext, + noEmit: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + strict: false, + }); + + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + }); + + Promise.all([ + fetch("/types/type-utils.d.ts").then((r) => r.text()), + fetch("/types/infer.d.ts").then((r) => r.text()), + fetch("/types/lib.d.ts").then((r) => r.text()), + ]).then(([typeUtilsDts, inferDts, libDts]) => { + const stripImportsExports = (content: string) => + content + .replace(/import\s+{[^}]*}\s+from\s+['""][^'"]*['""];?\s*/g, "") + .replace(/import\s+.*\s+from\s+['""][^'"]*['""];?\s*/g, "") + .replace(/^export\s+{[^}]*};?\s*/gm, "") + .replace(/^export\s+/gm, "") + .replace(/\/\/# sourceMappingURL=.*/g, "") + .replace(/\/\/#region.*\n?/g, "") + .replace(/\/\/#endregion.*\n?/g, ""); + + const combinedTypes = ` +${stripImportsExports(typeUtilsDts)} +${stripImportsExports(inferDts)} +${stripImportsExports(libDts)} +`; + + const moduleDeclaration = `declare module "prototypey" { +${combinedTypes} +}`; + + monaco.languages.typescript.typescriptDefaults.addExtraLib( + moduleDeclaration, + "prototypey.d.ts", + ); + + setIsReady(true); + onReady?.(); + }); + }, [monaco, onReady]); + + if (!isReady) { + return ( +
+
+ Input +
+
+ Loading... +
+
+ ); + } + + return ( +
+
+ Input +
+
+ onChange(value || "")} + theme="vs-light" + options={{ + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: "on", + renderLineHighlight: "all", + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + padding: { top: 16, bottom: 16 }, + }} + /> +
+
+ ); +} diff --git a/packages/site/src/components/Header.tsx b/packages/site/src/components/Header.tsx new file mode 100644 index 0000000..87ff40e --- /dev/null +++ b/packages/site/src/components/Header.tsx @@ -0,0 +1,31 @@ +export function Header() { + return ( +
+
+

+ at://prototypey +

+

+ Type-safe lexicon inference for ATProto schemas +

+
+
+ ); +} diff --git a/packages/site/src/components/OutputPanel.tsx b/packages/site/src/components/OutputPanel.tsx new file mode 100644 index 0000000..a771226 --- /dev/null +++ b/packages/site/src/components/OutputPanel.tsx @@ -0,0 +1,60 @@ +import MonacoEditor from "@monaco-editor/react"; + +interface OutputPanelProps { + output: { + json: string; + typeInfo: string; + error: string; + }; +} + +export function OutputPanel({ output }: OutputPanelProps) { + return ( +
+
+ Output +
+
+ {output.error ? ( +
+ Error: {output.error} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/packages/site/src/components/Playground.tsx b/packages/site/src/components/Playground.tsx new file mode 100644 index 0000000..4be9357 --- /dev/null +++ b/packages/site/src/components/Playground.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useRef } from "react"; +import { Editor } from "./Editor"; +import { OutputPanel } from "./OutputPanel"; +import { lx } from "prototypey"; +import { useMonaco } from "@monaco-editor/react"; +import type * as Monaco from "monaco-editor"; + +let tsWorkerInstance: Monaco.languages.typescript.TypeScriptWorker | null = + null; + +export function Playground() { + const [code, setCode] = useState(DEFAULT_CODE); + const [output, setOutput] = useState({ json: "", typeInfo: "", error: "" }); + const [editorReady, setEditorReady] = useState(false); + const monaco = useMonaco(); + const tsWorkerRef = + useRef(null); + + const handleCodeChange = (newCode: string) => { + setCode(newCode); + }; + + const handleEditorReady = () => { + setEditorReady(true); + }; + + useEffect(() => { + if (monaco && editorReady && !tsWorkerRef.current && !tsWorkerInstance) { + const initWorker = async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + const worker = + await monaco.languages.typescript.getTypeScriptWorker(); + const uri = monaco.Uri.parse("file:///main.ts"); + const client = await worker(uri); + tsWorkerRef.current = client; + tsWorkerInstance = client; + } catch (err) { + console.error("Failed to initialize TypeScript worker:", err); + } + }; + initWorker(); + } + }, [monaco, editorReady]); + + useEffect(() => { + const timeoutId = setTimeout(async () => { + try { + const nsMatch = code.match( + /const\s+ns\s*=\s*lx\.namespace\([^]*?\}\s*\);/, + ); + if (!nsMatch) { + throw new Error("No namespace definition found"); + } + + const cleanedCode = nsMatch[0]; + const wrappedCode = `${cleanedCode}\nreturn ns;`; + const fn = new Function("lx", wrappedCode); + const result = fn(lx); + let typeInfo = "// Hover over .infer in the editor to see the type"; + + if (monaco && tsWorkerRef.current) { + try { + const uri = monaco.Uri.parse("file:///main.ts"); + const existingModel = monaco.editor.getModel(uri); + + if (existingModel) { + const inferPosition = code.indexOf(`ns.infer`); + if (inferPosition !== -1) { + const offset = inferPosition + `ns.infer`.length - 1; + + const quickInfo = + await tsWorkerRef.current.getQuickInfoAtPosition( + uri.toString(), + offset, + ); + + if (quickInfo?.displayParts) { + const typeText = quickInfo.displayParts + .map((part: { text: string }) => part.text) + .join(""); + + const propertyMatch = typeText.match( + /\(property\)\s+.*?\.infer:\s*([\s\S]+?)$/, + ); + if (propertyMatch) { + typeInfo = formatTypeString(propertyMatch[1]); + } + } + } + } + } catch (err) { + console.error("Type extraction error:", err); + } + } + + if (result && typeof result === "object" && "json" in result) { + const jsonOutput = (result as { json: unknown }).json; + setOutput({ + json: JSON.stringify(jsonOutput, null, 2), + typeInfo, + error: "", + }); + } else { + setOutput({ + json: JSON.stringify(result, null, 2), + typeInfo, + error: "", + }); + } + } catch (error) { + setOutput({ + json: "", + typeInfo: "", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }, 500); + + return () => clearTimeout(timeoutId); + }, [code, monaco]); + + return ( +
+
+ +
+
+ +
+
+ ); +} + +function formatTypeString(typeStr: string): string { + let formatted = typeStr.trim(); + + formatted = formatted.replace(/\s+/g, " "); + formatted = formatted.replace(/;\s*/g, "\n"); + formatted = formatted.replace(/{\s*/g, "{\n"); + formatted = formatted.replace(/\s*}/g, "\n}"); + + const lines = formatted.split("\n"); + let indentLevel = 0; + const indentedLines: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith("}")) { + indentLevel = Math.max(0, indentLevel - 1); + } + + indentedLines.push(" ".repeat(indentLevel) + trimmed); + + if (trimmed.endsWith("{") && !trimmed.includes("}")) { + indentLevel++; + } + } + + return indentedLines.join("\n"); +} + +const DEFAULT_CODE = `import { lx, type Infer } from "prototypey"; + +const ns = lx.namespace("app.bsky.actor.profile", { + main: lx.record({ + key: "self", + record: lx.object({ + displayName: lx.string({ maxLength: 64, maxGraphemes: 64 }), + description: lx.string({ maxLength: 256, maxGraphemes: 256 }), + }), + }), +}); + +type ProfileInferred = Infer; + +const aProfile: ProfileInferred = { + $type: "app.bsky.actor.profile", + displayName: "Benny Harvey" +}`; diff --git a/packages/site/src/index.css b/packages/site/src/index.css new file mode 100644 index 0000000..cef07a7 --- /dev/null +++ b/packages/site/src/index.css @@ -0,0 +1,32 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + line-height: 1.5; + font-weight: 400; + color: #213547; + background-color: #ffffff; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + display: flex; + min-width: 320px; + min-height: 100vh; +} + +#root { + width: 100%; + display: flex; + flex-direction: column; +} diff --git a/packages/site/src/main.tsx b/packages/site/src/main.tsx new file mode 100644 index 0000000..9192dbe --- /dev/null +++ b/packages/site/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import { App } from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/site/src/vite-env.d.ts b/packages/site/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/site/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/site/tests/components/Editor.test.tsx b/packages/site/tests/components/Editor.test.tsx new file mode 100644 index 0000000..147bc2a --- /dev/null +++ b/packages/site/tests/components/Editor.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Editor } from "../../src/components/Editor"; + +vi.mock("@monaco-editor/react", () => ({ + default: ({ value, onChange }: any) => ( +