From 35b1e81f0de44a57fe4c987364b886cf61ef3ec8 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 12 Aug 2026 10:10:02 +0200 Subject: [PATCH 1/2] feat(editor): add TypeScript script tooling --- bun.lock | 1 + docs/e2e/feature-matrix.md | 3 + docs/features/site-shell.md | 13 + package.json | 1 + src/__tests__/admin/siteItemNames.test.ts | 11 + .../architecture/bundle-size-budgets.test.ts | 13 + ...pescript-language-worker-isolation.test.ts | 35 +++ .../code-editor/codeMirrorEditor.test.tsx | 89 +++++- .../code-editor/scriptSettingsPane.test.tsx | 29 ++ .../typescriptLanguageService.test.ts | 153 ++++++++++ .../code-editor/CodeEditorPanel.module.css | 59 +++- .../site/code-editor/CodeEditorPanel.tsx | 123 ++++++-- .../site/code-editor/CodeMirrorEditor.tsx | 278 +++++++++++++++++- .../site/code-editor/markdownDocumentation.ts | 58 ++++ .../code-editor/typescriptLanguageClient.ts | 132 +++++++++ .../typescriptLanguageServiceEngine.ts | 276 +++++++++++++++++ .../site/code-editor/typescriptProtocol.ts | 106 +++++++ .../site/code-editor/typescriptWorker.ts | 69 +++++ .../dialogs/SiteCreateDialog/siteItemNames.ts | 5 +- 19 files changed, 1400 insertions(+), 54 deletions(-) create mode 100644 src/__tests__/admin/siteItemNames.test.ts create mode 100644 src/__tests__/architecture/typescript-language-worker-isolation.test.ts create mode 100644 src/__tests__/code-editor/typescriptLanguageService.test.ts create mode 100644 src/admin/pages/site/code-editor/markdownDocumentation.ts create mode 100644 src/admin/pages/site/code-editor/typescriptLanguageClient.ts create mode 100644 src/admin/pages/site/code-editor/typescriptLanguageServiceEngine.ts create mode 100644 src/admin/pages/site/code-editor/typescriptProtocol.ts create mode 100644 src/admin/pages/site/code-editor/typescriptWorker.ts diff --git a/bun.lock b/bun.lock index 3a0333828..f47c8e7f7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "instatic", "dependencies": { + "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.5", diff --git a/docs/e2e/feature-matrix.md b/docs/e2e/feature-matrix.md index 545466ede..f1c3cfb6e 100644 --- a/docs/e2e/feature-matrix.md +++ b/docs/e2e/feature-matrix.md @@ -169,9 +169,12 @@ SITE-019 note: `visual-builder.e2e.ts` saves a styled Container subtree as a lay | ID | Priority | Auto | Area | User Goal | Setup | Path | Expected Outcome | Watch For | |---|---:|:---:|---|---|---|---|---|---| +| SITE-013 | P1 | partial | Code Editor | Author TypeScript site scripts with immediate type feedback | Site editor open | Code panel → New script → Code editor | `.ts`/`.tsx` scripts get DOM-aware completions, hover signatures, strict semantic diagnostics in a bounded collapsible Problems list, relative-file types, autosave, canvas execution, and publish compilation | worker startup, stale diagnostics, package types, completion keyboard reachability, classic-script confusion | | SITE-014 | P1 | partial | Dependencies | Declare runtime packages for site scripts and plugin modules | Site script or module with package import | Dependencies panel and runtime resolve endpoint | Missing imports are visible, safe dependencies resolve into a lock/importmap, and cached package files serve under `/_instatic/runtime/cache` | unsafe package names, stale lock/importmap, install failures, traversal-shaped cache paths | | SITE-016 | P1 | ✅ | Preview/Live | Compare the current draft with the live public route | Page has a published version and a later saved draft | Publish actions → Preview page; toolbar → Open live page | Preview iframe shows the current draft while the live route opens the last published output without admin chrome | draft/public leakage, stale live path, popup target, mobile overlay reachability | +SITE-013 note: focused Bun coverage verifies the worker protocol/client, strict DOM-aware TypeScript diagnostics, DOM completion and hover results, relative cross-file typing, bare-package handoff to runtime analysis, `.tsx` path creation, CodeMirror compiler-diagnostic merging, lazy compiler isolation, and worker bundle budget. The 2026-08-11 agent-browser run covers live type-error rendering, `window` completion UI, hover information, autosave/reload, publish, and anonymous runtime execution; package declaration acquisition remains future work. + SITE-014 note: focused Bun coverage spans the dependency panel, auto-resolve hook, client envelope validation, runtime handler normalization, module dependency/importmap filtering, script import analysis, runtime config, site runtime build, dependency resolver/cache, package importmap/server, and runtime asset publish injection. `tests/e2e/runtime-dependencies.e2e.ts` covers browser authoring of a site script import, Dependencies-panel missing package Add, live `canvas-confetti` registry/cache resolution, save/publish, public importmap emission, browser loading of the emitted `/_instatic/runtime/cache/...` package URL, and a 390px mobile path that authors a missing import, opens Dependencies, verifies no horizontal overflow, and confirms the Add action is reachable. Live registry/install failure UX permutations remain operator-run. SITE-016 note: `tests/e2e/preview-live.e2e.ts` creates a disposable page, publishes version A, saves draft version B without publishing, verifies the Preview page overlay iframe renders draft B, verifies the toolbar Open live page popup still serves published version A without editor chrome, and repeats preview opening at 390px to confirm the overlay remains reachable without document overflow. Issue #234 additionally gates Preview page through the server runtime-preview path so loop and media prefetch matches public rendering. Template-target and Content-entry live-path permutations remain lower-level or future browser coverage. diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index 4019d8b85..9e368c5c9 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -204,6 +204,19 @@ Schema source of truth: `src/core/files/schemas.ts`. Generated files (e.g. `package.json`, `vite.config.ts`) are hidden in the Site Explorer until the user ejects them. Files are created and renamed through the Site Explorer panel and edited with the CodeMirror-backed code editor. +TypeScript site scripts get semantic authoring support in that editor, not +just grammar highlighting. Opening a `.ts`/`.tsx` script lazily starts a +dedicated browser Worker containing TypeScript's language service and the +ES2020 + DOM standard-library declarations. The worker keeps an in-memory +project of authored TypeScript script files so CodeMirror can show strict type +diagnostics, DOM-aware completions, cross-file relative-import types, and hover +signatures without running the compiler on the UI thread. Bare npm imports stay +under the existing runtime dependency analyzer—the browser language service +does not pretend an installed package has declarations when none were loaded. +The worker is editor assistance only: esbuild remains the authoritative canvas +and publish compiler, and semantic type errors do not replace the publish-time +runtime validation gate. + ### Site Explorer organization — `SiteExplorerOrganization` Site Explorer organization is split by whether a section owns URL/file paths. diff --git a/package.json b/package.json index c58d39f40..78fc3ed56 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "doctor": "react-doctor" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.5", diff --git a/src/__tests__/admin/siteItemNames.test.ts b/src/__tests__/admin/siteItemNames.test.ts new file mode 100644 index 000000000..e691cee45 --- /dev/null +++ b/src/__tests__/admin/siteItemNames.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'bun:test' +import { buildScriptPath } from '@admin/shared/dialogs/SiteCreateDialog' + +describe('buildScriptPath', () => { + it('defaults scripts to TypeScript and preserves explicit TypeScript extensions', () => { + expect(buildScriptPath('analytics')).toBe('src/scripts/analytics.ts') + expect(buildScriptPath('src/scripts/runtime.ts')).toBe('src/scripts/runtime.ts') + expect(buildScriptPath('widget.tsx')).toBe('src/scripts/widget.tsx') + expect(buildScriptPath('worker.mts')).toBe('src/scripts/worker.mts') + }) +}) diff --git a/src/__tests__/architecture/bundle-size-budgets.test.ts b/src/__tests__/architecture/bundle-size-budgets.test.ts index 4bc727a37..0f4fa5ee0 100644 --- a/src/__tests__/architecture/bundle-size-budgets.test.ts +++ b/src/__tests__/architecture/bundle-size-budgets.test.ts @@ -198,6 +198,19 @@ const BUDGETS: ChunkBudget[] = [ 'Grew from ~606 KB when @codemirror/lang-html (bundling embedded CSS + ' + 'JS grammar) was added for the HTML-import editor.', }, + + // TypeScript's compiler and standard-library declarations are intentionally + // isolated behind a browser Worker created only for authored .ts/.tsx files. + // This cap makes a dependency upgrade explicit without charging the editor + // or admin startup chunks for semantic language tooling. + { + prefix: 'typescriptWorker-', + maxBytes: 7_600_000, + rationale: + 'lazy TypeScript language-service worker (current ~7.37 MB raw). ' + + 'Contains the TypeScript compiler plus ES2020/DOM declaration text, ' + + 'and is loaded only when a user opens a TypeScript site script.', + }, ] // --------------------------------------------------------------------------- diff --git a/src/__tests__/architecture/typescript-language-worker-isolation.test.ts b/src/__tests__/architecture/typescript-language-worker-isolation.test.ts new file mode 100644 index 000000000..c7c8af08b --- /dev/null +++ b/src/__tests__/architecture/typescript-language-worker-isolation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test' +import { readFileSync } from 'node:fs' + +const editorFile = (name: string) => readFileSync( + new URL(`../../admin/pages/site/code-editor/${name}`, import.meta.url), + 'utf8', +) + +describe('TypeScript language-service worker isolation', () => { + it('keeps the compiler behind a dedicated browser worker', () => { + const client = editorFile('typescriptLanguageClient.ts') + const worker = editorFile('typescriptWorker.ts') + const engine = editorFile('typescriptLanguageServiceEngine.ts') + const editor = editorFile('CodeMirrorEditor.tsx') + + expect(client).toContain("new Worker(new URL('./typescriptWorker.ts', import.meta.url)") + expect(worker).toContain("from './typescriptLanguageServiceEngine'") + expect(engine).toContain("from 'typescript'") + expect(client).not.toContain("from 'typescript'") + expect(editor).not.toContain("from 'typescript'") + }) + + it('loads TypeScript standard libraries inside the worker bundle', () => { + const worker = editorFile('typescriptWorker.ts') + expect(worker).toContain("'/node_modules/typescript/lib/lib.*.d.ts'") + expect(worker).toContain("query: '?raw'") + }) + + it('constrains language tooltips to the editor instead of the viewport', () => { + const editor = editorFile('CodeMirrorEditor.tsx') + expect(editor).toContain('const editorTooltipBoundary = tooltips({') + expect(editor).toContain('tooltipSpace: (view) => view.dom.getBoundingClientRect()') + expect(editor).toContain('editorTooltipBoundary,') + }) +}) diff --git a/src/__tests__/code-editor/codeMirrorEditor.test.tsx b/src/__tests__/code-editor/codeMirrorEditor.test.tsx index 2d6bc60eb..b2da2f4e2 100644 --- a/src/__tests__/code-editor/codeMirrorEditor.test.tsx +++ b/src/__tests__/code-editor/codeMirrorEditor.test.tsx @@ -1,9 +1,14 @@ import { afterEach, describe, expect, it } from 'bun:test' import React from 'react' -import { cleanup, render } from '@testing-library/react' +import { cleanup, render, waitFor } from '@testing-library/react' import { EditorView } from '@codemirror/view' import { diagnosticCount } from '@codemirror/lint' import CodeMirrorEditor from '@site/code-editor/CodeMirrorEditor' +import type { + TypeScriptWorkerRequest, + TypeScriptWorkerResponse, +} from '@site/code-editor/typescriptProtocol' +import { renderMarkdownDocumentation } from '@site/code-editor/markdownDocumentation' afterEach(cleanup) @@ -12,6 +17,21 @@ function nextFrame() { } describe('CodeMirrorEditor', () => { + it('renders TypeScript hover documentation as safe Markdown', () => { + const container = document.createElement('div') + renderMarkdownDocumentation( + container, + '**`window.document`** returns a reference.\n\n[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)', + ) + + expect(container.textContent).not.toContain('**') + expect(container.querySelector('strong code')?.textContent).toBe('window.document') + const link = container.querySelector('a') + expect(link?.textContent).toBe('MDN Reference') + expect(link?.href).toBe('https://developer.mozilla.org/docs/Web/API/Window/document') + expect(link?.target).toBe('_blank') + }) + it('can emit changes immediately for modal command surfaces', async () => { const changes: string[] = [] render( @@ -79,4 +99,71 @@ describe('CodeMirrorEditor', () => { expect(diagnosticCount(view.state)).toBe(1) expect(document.querySelector('.cm-lint-marker-error')).toBeTruthy() }) + + it('merges semantic TypeScript diagnostics from the lazy worker', async () => { + const originalWorker = globalThis.Worker + const reported: number[] = [] + + class DiagnosticWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + + postMessage(request: TypeScriptWorkerRequest) { + if (request.type !== 'diagnostics') return + const response: TypeScriptWorkerResponse = { + type: 'diagnostics', + requestId: request.requestId, + diagnostics: [{ + code: 2322, + severity: 'error', + message: "Type 'string' is not assignable to type 'number'.", + from: 6, + to: 11, + line: 1, + column: 6, + }], + } + queueMicrotask(() => this.onmessage?.(new MessageEvent('message', { data: response }))) + } + + terminate() {} + } + + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + writable: true, + value: DiagnosticWorker, + }) + + try { + render( + undefined} + onTypeScriptDiagnosticsChange={(diagnostics) => reported.push(diagnostics.length)} + />, + ) + + await waitFor(() => { + const editor = document.querySelector('.cm-editor') + const view = EditorView.findFromDOM(editor!)! + expect(diagnosticCount(view.state)).toBe(1) + }) + expect(reported).toContain(1) + expect(document.querySelector('.cm-lint-marker-error')).toBeTruthy() + } finally { + Object.defineProperty(globalThis, 'Worker', { + configurable: true, + writable: true, + value: originalWorker, + }) + } + }) }) diff --git a/src/__tests__/code-editor/scriptSettingsPane.test.tsx b/src/__tests__/code-editor/scriptSettingsPane.test.tsx index 7b29e4265..f91ea4905 100644 --- a/src/__tests__/code-editor/scriptSettingsPane.test.tsx +++ b/src/__tests__/code-editor/scriptSettingsPane.test.tsx @@ -47,6 +47,35 @@ function resetStore() { beforeEach(resetStore) describe('Script runtime settings pane', () => { + it('keeps many problems bounded and collapses to the live error count', () => { + const diagnostics = Array.from({ length: 12 }, (_, index) => ({ + code: `runtime-error-${index}`, + severity: 'error' as const, + message: `Problem ${index + 1}: ${'long diagnostic details '.repeat(8)}`, + path: 'src/scripts/celebrate.ts', + line: index + 1, + column: 4, + })) + + render() + + const problems = screen.getByRole('region', { name: 'Script problems' }) + expect(screen.getByText('12 errors')).toBeDefined() + expect(problems.querySelectorAll('li')).toHaveLength(12) + + const minimize = screen.getByRole('button', { name: 'Minimize Problems' }) + expect(minimize.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(minimize) + + expect(screen.getByText('12 errors')).toBeDefined() + expect(problems.querySelectorAll('li')).toHaveLength(0) + const expand = screen.getByRole('button', { name: 'Expand Problems' }) + expect(expand.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(expand) + expect(problems.querySelectorAll('li')).toHaveLength(12) + }) + it('renders next to active script files and updates runtime config', () => { render() diff --git a/src/__tests__/code-editor/typescriptLanguageService.test.ts b/src/__tests__/code-editor/typescriptLanguageService.test.ts new file mode 100644 index 000000000..818c15dce --- /dev/null +++ b/src/__tests__/code-editor/typescriptLanguageService.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' +import { TypeScriptLanguageServiceEngine } from '@site/code-editor/typescriptLanguageServiceEngine' +import { TypeScriptLanguageClient } from '@site/code-editor/typescriptLanguageClient' +import type { + TypeScriptWorkerRequest, + TypeScriptWorkerResponse, +} from '@site/code-editor/typescriptProtocol' + +function loadTypeScriptLibraries(): Record { + const typescriptDirectory = dirname(fileURLToPath(import.meta.resolve('typescript'))) + const libraries: Record = {} + const seen = new Set() + + function load(name: string): void { + if (seen.has(name)) return + seen.add(name) + const content = readFileSync(join(typescriptDirectory, name), 'utf8') + libraries[name] = content + for (const match of content.matchAll(/ { + while (engines.length > 0) engines.pop()!.dispose() +}) + +describe('TypeScriptLanguageServiceEngine', () => { + it('provides DOM-aware diagnostics, completions, and hover information', () => { + const engine = createEngine() + const content = [ + 'const title: number = document.title', + 'window.loc', + ].join('\n') + engine.sync([{ path: 'src/scripts/main.ts', content }]) + + expect(engine.diagnostics('src/scripts/main.ts')).toContainEqual(expect.objectContaining({ + code: 2322, + message: "Type 'string' is not assignable to type 'number'.", + line: 1, + column: 6, + })) + expect( + engine.completions('src/scripts/main.ts', content.length)?.options.map((option) => option.label), + ).toContain('location') + expect(engine.hover('src/scripts/main.ts', content.indexOf('document') + 2)).toEqual( + expect.objectContaining({ signature: 'var document: Document' }), + ) + }) + + it('shares authored types across relative imports while leaving package resolution to the runtime', () => { + const engine = createEngine() + engine.sync([ + { + path: 'src/scripts/types.ts', + content: 'export interface User { name: string }', + }, + { + path: 'src/scripts/main.ts', + content: [ + "import type { User } from './types'", + "import confetti from 'canvas-confetti'", + "const user: User = { name: 42 }", + 'void confetti', + ].join('\n'), + }, + ]) + + const diagnostics = engine.diagnostics('src/scripts/main.ts') + expect(diagnostics).toContainEqual(expect.objectContaining({ code: 2322 })) + expect(diagnostics.some((diagnostic) => diagnostic.code === 2307)).toBe(false) + + engine.update( + 'src/scripts/main.ts', + "import type { Missing } from './missing'\nconst value: Missing = {}", + ) + expect(engine.diagnostics('src/scripts/main.ts')).toContainEqual(expect.objectContaining({ + code: 2307, + message: "Cannot find module './missing' or its corresponding type declarations.", + })) + }) +}) + +class FakeWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + requests: TypeScriptWorkerRequest[] = [] + terminated = false + + postMessage(request: TypeScriptWorkerRequest): void { + this.requests.push(request) + if (!('requestId' in request)) return + let response: TypeScriptWorkerResponse + if (request.type === 'diagnostics') { + response = { type: 'diagnostics', requestId: request.requestId, diagnostics: [] } + } else if (request.type === 'completions') { + response = { + type: 'completions', + requestId: request.requestId, + result: { from: request.position, to: request.position, options: [] }, + } + } else { + response = { type: 'hover', requestId: request.requestId } + } + queueMicrotask(() => this.onmessage?.(new MessageEvent('message', { data: response }))) + } + + terminate(): void { + this.terminated = true + } +} + +describe('TypeScriptLanguageClient', () => { + it('correlates validated worker responses and terminates cleanly', async () => { + const worker = new FakeWorker() + const client = new TypeScriptLanguageClient(() => worker) + client.syncProject([{ path: 'src/scripts/main.ts', content: 'window.location' }]) + client.updateFile('src/scripts/main.ts', 'window.document') + + expect(await client.diagnostics('src/scripts/main.ts')).toEqual([]) + expect(await client.completions('src/scripts/main.ts', 5)).toEqual({ + from: 5, + to: 5, + options: [], + }) + expect(await client.hover('src/scripts/main.ts', 2)).toBeUndefined() + expect(worker.requests.map((request) => request.type)).toEqual([ + 'sync', + 'update', + 'diagnostics', + 'completions', + 'hover', + ]) + + client.dispose() + expect(worker.terminated).toBe(true) + }) +}) diff --git a/src/admin/pages/site/code-editor/CodeEditorPanel.module.css b/src/admin/pages/site/code-editor/CodeEditorPanel.module.css index 2048e9e26..7a5b914bd 100644 --- a/src/admin/pages/site/code-editor/CodeEditorPanel.module.css +++ b/src/admin/pages/site/code-editor/CodeEditorPanel.module.css @@ -76,26 +76,34 @@ .problems { flex: 0 0 auto; - max-height: 132px; + max-height: 116px; display: flex; flex-direction: column; border-top: 1px solid var(--border-muted); background: var(--bg-surface-2); } +.problemsCollapsed { + max-height: none; +} + .problemsHeader { - min-height: 30px; + min-height: 26px; display: flex; align-items: center; justify-content: space-between; gap: var(--space-s); - padding: 0 var(--space-m); + padding: 0 var(--space-s) 0 var(--space-m); border-bottom: 1px solid var(--border-muted); } +.problemsCollapsed .problemsHeader { + border-bottom: 0; +} + .problemsTitle, .problemsStatus { - font-size: var(--text-xs); + font-size: var(--text-3xs); line-height: 1.2; } @@ -114,23 +122,42 @@ color: var(--danger-text); } +.problemsActions { + display: flex; + align-items: center; + gap: var(--space-2xs); + min-width: 0; +} + +.problemsToggle { + color: var(--text-subtle); +} + +.problemsContent { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + .problemsList { min-height: 0; margin: 0; - padding: var(--space-xs) 0; + padding: var(--space-3xs) 0; overflow: auto; list-style: none; + scrollbar-gutter: stable; } .problem { display: grid; - grid-template-columns: minmax(140px, 0.36fr) minmax(0, 1fr); - gap: var(--space-m); - padding: var(--space-2xs) var(--space-m); - border-left: 2px solid transparent; + grid-template-columns: minmax(104px, 0.32fr) minmax(0, 1fr); + gap: var(--space-s); + padding: var(--space-3xs) var(--space-m); + border-left: 1px solid transparent; font-family: var(--font-mono); - font-size: var(--text-xs); - line-height: 1.4; + font-size: var(--text-3xs); + line-height: 1.35; } .problem[data-severity='error'] { @@ -149,16 +176,20 @@ } .problemMessage { + display: -webkit-box; + overflow: hidden; color: var(--text); overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .problemsEmpty { margin: 0; - padding: var(--space-s) var(--space-m); + padding: var(--space-xs) var(--space-m); color: var(--text-muted); - font-size: var(--text-xs); - line-height: 1.4; + font-size: var(--text-3xs); + line-height: 1.35; } @media (max-width: 760px) { diff --git a/src/admin/pages/site/code-editor/CodeEditorPanel.tsx b/src/admin/pages/site/code-editor/CodeEditorPanel.tsx index 9e8ca775b..c8972bd5a 100644 --- a/src/admin/pages/site/code-editor/CodeEditorPanel.tsx +++ b/src/admin/pages/site/code-editor/CodeEditorPanel.tsx @@ -26,7 +26,9 @@ * Editor chrome stays neutral; CodeMirror syntax uses GitHub Dark-style tokens. */ -import { Suspense, lazy, useEffect, useRef, type CSSProperties } from 'react' +import { Suspense, lazy, useEffect, useId, useRef, useState, type CSSProperties } from 'react' +import { ChevronUpIcon } from 'pixel-art-icons/icons/chevron-up' +import { MinusIcon } from 'pixel-art-icons/icons/minus' import { useEditorStore } from '@site/store/store' import { PanelHeader } from '@admin/shared/PanelHeader' import { useDraggablePanel } from '@admin/shared/FloatingWindow' @@ -34,10 +36,12 @@ import { ImagePreview } from './ImagePreview' import { ScriptSettingsPane } from './ScriptSettingsPane' import { StyleSettingsPane } from './StyleSettingsPane' import { EmptyState } from '@ui/components/EmptyState' +import { Button } from '@ui/components/Button' import { cn } from '@ui/cn' import type { SiteFile } from '@core/files/schemas' import type { SiteRuntimeDiagnostic } from '@core/site-runtime' import type { CodeLanguage } from './CodeMirrorEditor' +import type { TypeScriptEditorDiagnostic } from './typescriptProtocol' import type { RuntimeScriptValidationState } from '@site/hooks/useRuntimeScriptDiagnostics' import styles from './CodeEditorPanel.module.css' @@ -45,7 +49,11 @@ import styles from './CodeEditorPanel.module.css' function fileLanguage(file: SiteFile): CodeLanguage { switch (file.type) { case 'component': return 'tsx' - case 'script': return 'ts' + case 'script': + if (/\.tsx$/.test(file.path)) return 'tsx' + if (/\.jsx$/.test(file.path)) return 'jsx' + if (/\.[cm]?js$/.test(file.path)) return 'javascript' + return 'ts' case 'style': return 'css' case 'config': if (file.path.endsWith('.json')) return 'json' @@ -92,6 +100,10 @@ export function CodeEditorPanel({ runtimeValidation }: CodeEditorPanelProps) { const closeEditor = useEditorStore((s) => s.closeEditor) const updateFileContent = useEditorStore((s) => s.updateFileContent) const updateNodeProps = useEditorStore((s) => s.updateNodeProps) + const [typeScriptProblems, setTypeScriptProblems] = useState<{ + docKey: string | null + diagnostics: TypeScriptEditorDiagnostic[] + }>({ docKey: null, diagnostics: [] }) // Find the active file (null when no file is open or loading site) const activeFile = activeEditorFileId && site @@ -168,6 +180,18 @@ export function CodeEditorPanel({ runtimeValidation }: CodeEditorPanelProps) { (!diagnostic.fileId && diagnostic.path === activeFile.path) )) : EMPTY_DIAGNOSTICS + const activeTypeScriptDiagnostics = activeFile && typeScriptProblems.docKey === activeFile.id + ? typeScriptProblems.diagnostics.map((diagnostic): SiteRuntimeDiagnostic => ({ + code: `typescript-${diagnostic.code}`, + severity: diagnostic.severity, + message: `TS${diagnostic.code}: ${diagnostic.message}`, + fileId: activeFile.id, + path: activeFile.path, + line: diagnostic.line, + column: diagnostic.column, + })) + : EMPTY_DIAGNOSTICS + const allProblems = [...runtimeDiagnostics, ...activeTypeScriptDiagnostics] // Editor props for the active document — either a node-prop buffer or a file. const editorDoc = activeCodeBuffer @@ -242,11 +266,19 @@ export function CodeEditorPanel({ runtimeValidation }: CodeEditorPanelProps) { language={editorDoc.language} onChange={editorDoc.onChange} diagnostics={isScriptFile ? activeFileDiagnostics : EMPTY_DIAGNOSTICS} + filePath={isScriptFile ? activeFile?.path : undefined} + projectFiles={isScriptFile ? site?.files : undefined} + onTypeScriptDiagnosticsChange={isScriptFile && activeFile + ? (nextDiagnostics) => setTypeScriptProblems({ + docKey: activeFile.id, + diagnostics: nextDiagnostics, + }) + : undefined} /> {isScriptFile && ( )} @@ -274,6 +306,8 @@ function RuntimeProblems({ diagnostics: SiteRuntimeDiagnostic[] validation?: RuntimeScriptValidationState }) { + const [collapsed, setCollapsed] = useState(false) + const contentId = useId() const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length const statusLabel = validation?.status === 'validating' ? 'Checking…' @@ -282,38 +316,63 @@ function RuntimeProblems({ : 'No errors' return ( -
+
Problems - 0 && styles.problemsStatusError)} - role="status" - aria-live="polite" - > - {statusLabel} - +
+ 0 && styles.problemsStatusError)} + role="status" + aria-live="polite" + > + {statusLabel} + + +
- {validation?.status === 'unavailable' ? ( -

- {validation.errorMessage ?? 'Code check unavailable. Publish will still validate the scripts.'} -

- ) : diagnostics.length > 0 ? ( -
    - {diagnostics.map((diagnostic) => ( -
  • - {diagnosticLocation(diagnostic)} - {diagnostic.message} -
  • - ))} -
- ) : ( -

- {validation?.status === 'validating' ? 'Running the publisher compiler…' : 'No script problems found.'} -

+ {!collapsed && ( +
+ {diagnostics.length > 0 ? ( +
    + {diagnostics.map((diagnostic) => ( +
  • + {diagnosticLocation(diagnostic)} + {diagnostic.message} +
  • + ))} +
+ ) : validation?.status === 'unavailable' ? ( +

+ {validation.errorMessage ?? 'Code check unavailable. Publish will still validate the scripts.'} +

+ ) : ( +

+ {validation?.status === 'validating' ? 'Running the publisher compiler…' : 'No script problems found.'} +

+ )} +
)}
) diff --git a/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx b/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx index 243b45a54..28a8161d6 100644 --- a/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx +++ b/src/admin/pages/site/code-editor/CodeMirrorEditor.tsx @@ -32,6 +32,12 @@ import { useRef, useEffect, useEffectEvent, useCallback } from 'react' import { EditorView, basicSetup } from 'codemirror' import { EditorState } from '@codemirror/state' import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { + autocompletion, + type CompletionContext, + type CompletionResult, +} from '@codemirror/autocomplete' +import { hoverTooltip, tooltips, type Tooltip } from '@codemirror/view' import { javascript } from '@codemirror/lang-javascript' import { css } from '@codemirror/lang-css' import { json } from '@codemirror/lang-json' @@ -41,6 +47,15 @@ import { tags as t } from '@lezer/highlight' import type { Extension } from '@codemirror/state' import { lintGutter, setDiagnostics, type Diagnostic } from '@codemirror/lint' import type { SiteRuntimeDiagnostic } from '@core/site-runtime' +import { + createTypeScriptLanguageClient, + type TypeScriptLanguageClient, +} from './typescriptLanguageClient' +import type { + TypeScriptEditorDiagnostic, + TypeScriptProjectFile, +} from './typescriptProtocol' +import { renderMarkdownDocumentation } from './markdownDocumentation' // --------------------------------------------------------------------------- // GitHub Dark-inspired CM6 theme — CSS custom properties only. @@ -98,6 +113,46 @@ const achromatic = EditorView.theme({ border: '1px solid var(--overlay-10)', color: 'var(--text)', }, + '.cm-typescript-hover': { + maxWidth: 'min(340px, calc(100vw - 32px))', + padding: 'var(--space-s) var(--space-m)', + fontFamily: 'var(--font-mono)', + fontSize: 'var(--text-xs)', + lineHeight: '1.5', + overflowWrap: 'anywhere', + }, + '.cm-typescript-hover-signature': { + color: 'var(--syntax-entity)', + whiteSpace: 'pre-wrap', + }, + '.cm-typescript-hover-documentation': { + marginTop: 'var(--space-xs)', + color: 'var(--text-muted)', + fontFamily: 'var(--font-sans)', + whiteSpace: 'pre-wrap', + }, + '.cm-typescript-hover-documentation p': { + margin: '0', + }, + '.cm-typescript-hover-documentation p + p': { + marginTop: 'var(--space-xs)', + }, + '.cm-typescript-hover-documentation strong': { + color: 'var(--text)', + fontWeight: '600', + }, + '.cm-typescript-hover-documentation code': { + padding: '0 var(--space-3xs)', + borderRadius: 'var(--radius-sm)', + backgroundColor: 'var(--overlay-10)', + color: 'var(--syntax-string)', + fontFamily: 'var(--font-mono)', + }, + '.cm-typescript-hover-documentation a': { + color: 'var(--syntax-constant)', + textDecoration: 'underline', + textUnderlineOffset: '2px', + }, '.cm-lintRange-error': { textDecorationColor: 'var(--danger)', }, @@ -238,6 +293,8 @@ const readableSyntaxHighlighting = syntaxHighlighting(readableHighlightStyle) export type CodeLanguage = | 'tsx' | 'ts' + | 'jsx' + | 'javascript' | 'css' | 'json' | 'markdown' @@ -251,6 +308,10 @@ function getLanguageExtensions(language: CodeLanguage): Extension[] { return [javascript({ jsx: true, typescript: true })] case 'ts': return [javascript({ typescript: true })] + case 'jsx': + return [javascript({ jsx: true })] + case 'javascript': + return [javascript()] case 'css': return [css()] case 'json': @@ -290,6 +351,12 @@ interface CodeMirrorEditorProps { changeDelayMs?: number /** Authoritative publisher-compiler diagnostics for this document. */ diagnostics?: SiteRuntimeDiagnostic[] + /** Site-relative path used by the TypeScript language-service project. */ + filePath?: string + /** Other authored files available for relative imports and shared types. */ + projectFiles?: readonly { path: string; content?: string }[] + /** Reports non-blocking semantic TypeScript diagnostics to the Problems panel. */ + onTypeScriptDiagnosticsChange?: (diagnostics: TypeScriptEditorDiagnostic[]) => void } const EMPTY_DIAGNOSTICS: SiteRuntimeDiagnostic[] = [] @@ -316,6 +383,117 @@ function codeMirrorDiagnostics( }) } +function typeScriptCodeMirrorDiagnostics( + document: EditorState['doc'], + diagnostics: TypeScriptEditorDiagnostic[], +): Diagnostic[] { + return diagnostics.map((diagnostic) => ({ + from: Math.max(0, Math.min(diagnostic.from, document.length)), + to: Math.max(0, Math.min(diagnostic.to, document.length)), + severity: diagnostic.severity, + message: `TS${diagnostic.code}: ${diagnostic.message}`, + source: 'TypeScript', + })) +} + +function dispatchCombinedDiagnostics( + view: EditorView, + runtimeDiagnostics: SiteRuntimeDiagnostic[], + typeScriptDiagnostics: TypeScriptEditorDiagnostic[], +): void { + view.dispatch(setDiagnostics(view.state, [ + ...codeMirrorDiagnostics(view.state.doc, runtimeDiagnostics), + ...typeScriptCodeMirrorDiagnostics(view.state.doc, typeScriptDiagnostics), + ])) +} + +function isTypeScriptLanguage(language: CodeLanguage): boolean { + return language === 'ts' || language === 'tsx' +} + +/** Keep completion and hover popups inside the visible editor surface. */ +const editorTooltipBoundary = tooltips({ + tooltipSpace: (view) => view.dom.getBoundingClientRect(), +}) + +function typeScriptProject( + files: readonly { path: string; content?: string }[], + activePath: string, + activeContent: string, +): TypeScriptProjectFile[] { + const project = files.flatMap((file) => ( + typeof file.content === 'string' && /\.(?:[cm]?ts|tsx)$/.test(file.path) + ? [{ path: file.path, content: file.path === activePath ? activeContent : file.content }] + : [] + )) + if (!project.some((file) => file.path === activePath)) { + project.push({ path: activePath, content: activeContent }) + } + return project +} + +function typeScriptCompletionSource( + client: TypeScriptLanguageClient, + filePath: string, +) { + return async (context: CompletionContext): Promise => { + const word = context.matchBefore(/[\w$]*/) + if (!context.explicit && (!word || (word.from === word.to && context.pos === 0))) return null + client.updateFile(filePath, context.state.doc.toString()) + try { + const result = await client.completions(filePath, context.pos) + if (!result) return null + const from = Math.max(0, Math.min(result.from, context.state.doc.length)) + const to = Math.max(from, Math.min(result.to, context.state.doc.length)) + return { + from, + to, + options: result.options, + } + } catch (error) { + console.warn('[CodeMirrorEditor] TypeScript completions unavailable:', error) + return null + } + } +} + +function typeScriptHoverSource( + client: TypeScriptLanguageClient, + filePath: string, +) { + return async (view: EditorView, position: number): Promise => { + client.updateFile(filePath, view.state.doc.toString()) + try { + const result = await client.hover(filePath, position) + if (!result) return null + return { + pos: Math.max(0, Math.min(result.from, view.state.doc.length)), + end: Math.max(0, Math.min(result.to, view.state.doc.length)), + create: () => { + const dom = document.createElement('div') + dom.className = 'cm-typescript-hover' + + const signature = document.createElement('div') + signature.className = 'cm-typescript-hover-signature' + signature.textContent = result.signature + dom.append(signature) + + if (result.documentation) { + const documentation = document.createElement('div') + documentation.className = 'cm-typescript-hover-documentation' + renderMarkdownDocumentation(documentation, result.documentation) + dom.append(documentation) + } + return { dom } + }, + } + } catch (error) { + console.warn('[CodeMirrorEditor] TypeScript hover unavailable:', error) + return null + } + } +} + export default function CodeMirrorEditor({ docKey, value, @@ -323,9 +501,16 @@ export default function CodeMirrorEditor({ onChange, changeDelayMs = 250, diagnostics = EMPTY_DIAGNOSTICS, + filePath, + projectFiles = EMPTY_PROJECT_FILES, + onTypeScriptDiagnosticsChange, }: CodeMirrorEditorProps) { const containerRef = useRef(null) const viewRef = useRef(null) + const typeScriptClientRef = useRef(null) + const typeScriptDiagnosticsRef = useRef([]) + const runtimeDiagnosticsRef = useRef(diagnostics) + const refreshTypeScriptDiagnosticsRef = useRef<(() => void) | null>(null) // Refs to hold pending debounce state. Using refs (not state) so that reads // inside the CM6 update listener always see the current values without @@ -339,6 +524,10 @@ export default function CodeMirrorEditor({ useEffect(() => { onChangeRef.current = onChange }, [onChange]) + const onTypeScriptDiagnosticsChangeRef = useRef(onTypeScriptDiagnosticsChange) + useEffect(() => { + onTypeScriptDiagnosticsChangeRef.current = onTypeScriptDiagnosticsChange + }, [onTypeScriptDiagnosticsChange]) // useCallback kept: stable identity for the [flush] useEffect dep array (exhaustive-deps). // Flush pending content to the store immediately (called on doc switch). @@ -361,18 +550,42 @@ export default function CodeMirrorEditor({ // lose cursor position. The effect only re-runs on docKey transitions, and // the cleanup's `flush()` persists any pending edit captured at mount time. const mountView = useEffectEvent((container: HTMLDivElement) => { - return new EditorView({ + const typeScriptClient = filePath && isTypeScriptLanguage(language) + ? createTypeScriptLanguageClient() + : null + typeScriptClientRef.current = typeScriptClient + if (typeScriptClient && filePath) { + typeScriptClient.syncProject(typeScriptProject(projectFiles, filePath, value)) + } + + let typeScriptDiagnosticsTimer: ReturnType | null = null + const view = new EditorView({ state: EditorState.create({ doc: value, extensions: [ basicSetup, ...getLanguageExtensions(language), + ...(typeScriptClient && filePath + ? [ + autocompletion({ override: [typeScriptCompletionSource(typeScriptClient, filePath)] }), + hoverTooltip(typeScriptHoverSource(typeScriptClient, filePath)), + ] + : []), readableSyntaxHighlighting, achromatic, + editorTooltipBoundary, lintGutter(), EditorView.updateListener.of((update) => { if (!update.docChanged) return const content = update.state.doc.toString() + if (typeScriptClient && filePath) { + typeScriptClient.updateFile(filePath, content) + if (typeScriptDiagnosticsTimer) clearTimeout(typeScriptDiagnosticsTimer) + typeScriptDiagnosticsTimer = setTimeout(() => { + refreshTypeScriptDiagnosticsRef.current?.() + typeScriptDiagnosticsTimer = null + }, 300) + } if (changeDelayMs <= 0) { if (timerRef.current) { clearTimeout(timerRef.current) @@ -397,14 +610,50 @@ export default function CodeMirrorEditor({ }), parent: container, }) + + if (typeScriptClient && filePath) { + refreshTypeScriptDiagnosticsRef.current = () => { + const activeClient = typeScriptClientRef.current + const activeView = viewRef.current + if (activeClient !== typeScriptClient || activeView !== view) return + void typeScriptClient.diagnostics(filePath) + .then((nextDiagnostics) => { + if (typeScriptClientRef.current !== typeScriptClient || viewRef.current !== view) return + typeScriptDiagnosticsRef.current = nextDiagnostics + dispatchCombinedDiagnostics( + view, + runtimeDiagnosticsRef.current, + nextDiagnostics, + ) + onTypeScriptDiagnosticsChangeRef.current?.(nextDiagnostics) + }) + .catch((error) => { + if (typeScriptClientRef.current !== typeScriptClient) return + console.error('[CodeMirrorEditor] TypeScript diagnostics unavailable:', error) + typeScriptDiagnosticsRef.current = [] + dispatchCombinedDiagnostics(view, runtimeDiagnosticsRef.current, []) + onTypeScriptDiagnosticsChangeRef.current?.([]) + }) + } + } + + return { + view, + dispose: () => { + if (typeScriptDiagnosticsTimer) clearTimeout(typeScriptDiagnosticsTimer) + typeScriptClient?.dispose() + }, + } }) useEffect(() => { const container = containerRef.current if (!container) return - const view = mountView(container) + const mounted = mountView(container) + const view = mounted.view viewRef.current = view + refreshTypeScriptDiagnosticsRef.current?.() return () => { // Flush-on-switch: persist any pending edit before destroying this view. @@ -412,17 +661,32 @@ export default function CodeMirrorEditor({ // debounce timer has not fired yet. flush() viewRef.current = null + typeScriptClientRef.current = null + refreshTypeScriptDiagnosticsRef.current = null + typeScriptDiagnosticsRef.current = [] + onTypeScriptDiagnosticsChangeRef.current?.([]) + mounted.dispose() view.destroy() } }, [docKey, flush]) useEffect(() => { + const client = typeScriptClientRef.current const view = viewRef.current - if (!view) return - view.dispatch(setDiagnostics( - view.state, - codeMirrorDiagnostics(view.state.doc, diagnostics), + if (!client || !view || !filePath || !isTypeScriptLanguage(language)) return + client.syncProject(typeScriptProject( + projectFiles, + filePath, + view.state.doc.toString(), )) + refreshTypeScriptDiagnosticsRef.current?.() + }, [filePath, language, projectFiles]) + + useEffect(() => { + const view = viewRef.current + if (!view) return + runtimeDiagnosticsRef.current = diagnostics + dispatchCombinedDiagnostics(view, diagnostics, typeScriptDiagnosticsRef.current) }, [diagnostics, docKey]) return ( @@ -432,3 +696,5 @@ export default function CodeMirrorEditor({ /> ) } + +const EMPTY_PROJECT_FILES: readonly { path: string; content?: string }[] = [] diff --git a/src/admin/pages/site/code-editor/markdownDocumentation.ts b/src/admin/pages/site/code-editor/markdownDocumentation.ts new file mode 100644 index 000000000..9e3501d65 --- /dev/null +++ b/src/admin/pages/site/code-editor/markdownDocumentation.ts @@ -0,0 +1,58 @@ +const INLINE_MARKDOWN = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)]\(([^)\s]+)\)/g + +function safeExternalUrl(rawUrl: string): string | null { + try { + const url = new URL(rawUrl) + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null + } catch { + return null + } +} + +function appendInlineMarkdown(parent: HTMLElement, source: string): void { + let cursor = 0 + for (const match of source.matchAll(INLINE_MARKDOWN)) { + const index = match.index ?? 0 + if (index > cursor) parent.append(document.createTextNode(source.slice(cursor, index))) + + const [, strongText, codeText, linkText, rawUrl] = match + if (strongText !== undefined) { + const strong = document.createElement('strong') + appendInlineMarkdown(strong, strongText) + parent.append(strong) + } else if (codeText !== undefined) { + const code = document.createElement('code') + code.textContent = codeText + parent.append(code) + } else if (linkText !== undefined && rawUrl !== undefined) { + const href = safeExternalUrl(rawUrl) + if (href) { + const link = document.createElement('a') + link.href = href + link.target = '_blank' + link.rel = 'noreferrer noopener' + appendInlineMarkdown(link, linkText) + parent.append(link) + } else { + appendInlineMarkdown(parent, linkText) + } + } + cursor = index + match[0].length + } + + if (cursor < source.length) parent.append(document.createTextNode(source.slice(cursor))) +} + +/** Render the small Markdown subset emitted by TypeScript's standard-library docs. */ +export function renderMarkdownDocumentation(container: HTMLElement, markdown: string): void { + for (const paragraphSource of markdown.trim().split(/\n{2,}/)) { + if (!paragraphSource) continue + const paragraph = document.createElement('p') + const lines = paragraphSource.split('\n') + lines.forEach((line, index) => { + if (index > 0) paragraph.append(document.createElement('br')) + appendInlineMarkdown(paragraph, line) + }) + container.append(paragraph) + } +} diff --git a/src/admin/pages/site/code-editor/typescriptLanguageClient.ts b/src/admin/pages/site/code-editor/typescriptLanguageClient.ts new file mode 100644 index 000000000..db660730b --- /dev/null +++ b/src/admin/pages/site/code-editor/typescriptLanguageClient.ts @@ -0,0 +1,132 @@ +import { safeParseValue } from '@core/utils/typeboxHelpers' +import { + TypeScriptWorkerResponseSchema, + type TypeScriptCompletionResult, + type TypeScriptEditorDiagnostic, + type TypeScriptHoverResult, + type TypeScriptProjectFile, + type TypeScriptWorkerRequest, + type TypeScriptWorkerResponse, +} from './typescriptProtocol' + +interface TypeScriptWorkerTransport { + onmessage: ((event: MessageEvent) => void) | null + onerror: ((event: ErrorEvent) => void) | null + postMessage(message: TypeScriptWorkerRequest): void + terminate(): void +} + +export type TypeScriptWorkerFactory = () => TypeScriptWorkerTransport + +interface PendingRequest { + resolve: (response: TypeScriptWorkerResponse) => void + reject: (error: Error) => void +} + +type TypeScriptAsyncRequest = + | { type: 'diagnostics'; path: string } + | { type: 'completions'; path: string; position: number } + | { type: 'hover'; path: string; position: number } + +function defaultWorkerFactory(): TypeScriptWorkerTransport { + return new Worker(new URL('./typescriptWorker.ts', import.meta.url), { + name: 'instatic-typescript', + type: 'module', + }) +} + +export class TypeScriptLanguageClient { + private readonly worker: TypeScriptWorkerTransport + private readonly pending = new Map() + private nextRequestId = 1 + private disposed = false + + constructor(workerFactory: TypeScriptWorkerFactory = defaultWorkerFactory) { + this.worker = workerFactory() + this.worker.onmessage = (event) => this.handleMessage(event.data) + this.worker.onerror = (event) => { + const error = new Error(event.message || 'TypeScript worker failed') + console.error('[CodeMirrorEditor] TypeScript worker error:', error) + this.disposed = true + this.worker.terminate() + this.rejectPending(error) + } + } + + syncProject(files: TypeScriptProjectFile[]): void { + if (this.disposed) return + this.worker.postMessage({ type: 'sync', files }) + } + + updateFile(path: string, content: string): void { + if (this.disposed) return + this.worker.postMessage({ type: 'update', path, content }) + } + + async diagnostics(path: string): Promise { + const response = await this.request({ type: 'diagnostics', path }) + if (response.type !== 'diagnostics') throw new Error('Unexpected TypeScript diagnostics response') + return response.diagnostics + } + + async completions(path: string, position: number): Promise { + const response = await this.request({ type: 'completions', path, position }) + if (response.type !== 'completions') throw new Error('Unexpected TypeScript completions response') + return response.result + } + + async hover(path: string, position: number): Promise { + const response = await this.request({ type: 'hover', path, position }) + if (response.type !== 'hover') throw new Error('Unexpected TypeScript hover response') + return response.result + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.worker.terminate() + this.rejectPending(new Error('TypeScript language service was closed')) + } + + private request( + request: TypeScriptAsyncRequest, + ): Promise { + if (this.disposed) return Promise.reject(new Error('TypeScript language service is closed')) + const requestId = String(this.nextRequestId++) + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }) + this.worker.postMessage({ ...request, requestId }) + }) + } + + private handleMessage(value: unknown): void { + const parsed = safeParseValue(TypeScriptWorkerResponseSchema, value) + if (!parsed.ok) { + const error = new Error('TypeScript worker returned an invalid response') + console.error('[CodeMirrorEditor] Invalid TypeScript worker response:', error) + this.rejectPending(error) + return + } + const response = parsed.value + const pending = this.pending.get(response.requestId) + if (!pending) return + this.pending.delete(response.requestId) + if (response.type === 'error') { + pending.reject(new Error(response.message)) + return + } + pending.resolve(response) + } + + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) pending.reject(error) + this.pending.clear() + } +} + +export function createTypeScriptLanguageClient( + workerFactory?: TypeScriptWorkerFactory, +): TypeScriptLanguageClient | null { + if (!workerFactory && typeof Worker === 'undefined') return null + return new TypeScriptLanguageClient(workerFactory) +} diff --git a/src/admin/pages/site/code-editor/typescriptLanguageServiceEngine.ts b/src/admin/pages/site/code-editor/typescriptLanguageServiceEngine.ts new file mode 100644 index 000000000..ec2f707b9 --- /dev/null +++ b/src/admin/pages/site/code-editor/typescriptLanguageServiceEngine.ts @@ -0,0 +1,276 @@ +import * as ts from 'typescript' +import type { + TypeScriptCompletionResult, + TypeScriptEditorDiagnostic, + TypeScriptHoverResult, + TypeScriptProjectFile, +} from './typescriptProtocol' + +const DEFAULT_LIBRARY = '/lib.es2020.full.d.ts' +const MAX_COMPLETIONS = 750 + +const COMPILER_OPTIONS: ts.CompilerOptions = { + allowImportingTsExtensions: true, + jsx: ts.JsxEmit.ReactJSX, + lib: ['lib.es2020.full.d.ts'], + module: ts.ModuleKind.ESNext, + moduleDetection: ts.ModuleDetectionKind.Force, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2020, +} + +interface VirtualFile { + content: string + version: number +} + +function virtualPath(path: string): string { + return `/${path.replace(/\\/g, '/').replace(/^\/+/, '')}` +} + +function libraryPath(path: string): string { + const name = path.slice(path.lastIndexOf('/') + 1) + return `/${name}` +} + +function isTypeScriptPath(path: string): boolean { + return /\.(?:[cm]?ts|tsx)$/.test(path) +} + +function diagnosticSeverity(category: ts.DiagnosticCategory): TypeScriptEditorDiagnostic['severity'] { + if (category === ts.DiagnosticCategory.Error) return 'error' + if (category === ts.DiagnosticCategory.Warning) return 'warning' + return 'info' +} + +function isBareModuleResolutionDiagnostic(diagnostic: ts.Diagnostic): boolean { + if (diagnostic.code !== 2307 && diagnostic.code !== 2792) return false + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + const match = message.match(/Cannot find module ['"]([^'"]+)['"]/) + if (!match) return false + return !match[1]!.startsWith('.') && !match[1]!.startsWith('/') +} + +function completionType(kind: ts.ScriptElementKind): string | undefined { + switch (kind) { + case ts.ScriptElementKind.classElement: + return 'class' + case ts.ScriptElementKind.interfaceElement: + case ts.ScriptElementKind.typeElement: + return 'type' + case ts.ScriptElementKind.enumElement: + return 'enum' + case ts.ScriptElementKind.functionElement: + case ts.ScriptElementKind.localFunctionElement: + case ts.ScriptElementKind.memberFunctionElement: + return 'function' + case ts.ScriptElementKind.constElement: + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + return 'variable' + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + return 'property' + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.externalModuleName: + return 'namespace' + case ts.ScriptElementKind.keyword: + return 'keyword' + case ts.ScriptElementKind.string: + return 'text' + default: + return undefined + } +} + +function directoriesFor(files: Iterable, rootPath: string): string[] { + const root = virtualPath(rootPath).replace(/\/$/, '') + const directories = new Set() + for (const file of files) { + if (!file.startsWith(`${root}/`)) continue + const relative = file.slice(root.length + 1) + const directory = relative.split('/')[0] + if (directory && relative.includes('/')) directories.add(directory) + } + return [...directories] +} + +/** + * One in-memory TypeScript project owned by the browser worker. The worker + * keeps the compiler and DOM libraries off the UI thread; this class keeps + * the language-service host deterministic and unit-testable. + */ +export class TypeScriptLanguageServiceEngine { + private readonly files = new Map() + private readonly libraries = new Map() + private projectVersion = 0 + private readonly languageService: ts.LanguageService + + constructor(libraries: Record) { + for (const [path, content] of Object.entries(libraries)) { + this.libraries.set(libraryPath(path), content) + } + + const host: ts.LanguageServiceHost = { + directoryExists: (path) => { + const prefix = `${virtualPath(path).replace(/\/$/, '')}/` + return prefix === '//' || [...this.files.keys(), ...this.libraries.keys()] + .some((file) => file.startsWith(prefix)) + }, + fileExists: (path) => this.hasFile(path), + getCompilationSettings: () => COMPILER_OPTIONS, + getCurrentDirectory: () => '/', + getDefaultLibFileName: () => DEFAULT_LIBRARY, + getDirectories: (path) => directoriesFor( + [...this.files.keys(), ...this.libraries.keys()], + path, + ), + getNewLine: () => '\n', + getProjectVersion: () => String(this.projectVersion), + getScriptFileNames: () => [DEFAULT_LIBRARY, ...this.files.keys()], + getScriptKind: (path) => path.endsWith('.tsx') + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS, + getScriptSnapshot: (path) => { + const content = this.readFile(path) + return content === undefined ? undefined : ts.ScriptSnapshot.fromString(content) + }, + getScriptVersion: (path) => String(this.files.get(virtualPath(path))?.version ?? 0), + readFile: (path) => this.readFile(path), + readDirectory: (rootDir, extensions) => { + const root = virtualPath(rootDir).replace(/\/$/, '') + return [...this.files.keys()].filter((file) => ( + file.startsWith(`${root}/`) && + (!extensions || extensions.some((extension) => file.endsWith(extension))) + )) + }, + useCaseSensitiveFileNames: () => true, + } + + this.languageService = ts.createLanguageService( + host, + ts.createDocumentRegistry(true, '/'), + ) + } + + sync(files: TypeScriptProjectFile[]): void { + const incoming = new Map( + files + .filter((file) => isTypeScriptPath(file.path)) + .map((file) => [virtualPath(file.path), file.content]), + ) + + let changed = false + for (const path of this.files.keys()) { + if (incoming.has(path)) continue + this.files.delete(path) + changed = true + } + for (const [path, content] of incoming) { + const current = this.files.get(path) + if (current?.content === content) continue + this.files.set(path, { content, version: (current?.version ?? 0) + 1 }) + changed = true + } + if (changed) this.projectVersion += 1 + } + + update(path: string, content: string): void { + const normalized = virtualPath(path) + if (!isTypeScriptPath(normalized)) return + const current = this.files.get(normalized) + if (current?.content === content) return + this.files.set(normalized, { content, version: (current?.version ?? 0) + 1 }) + this.projectVersion += 1 + } + + diagnostics(path: string): TypeScriptEditorDiagnostic[] { + const fileName = virtualPath(path) + const sourceFile = this.languageService.getProgram()?.getSourceFile(fileName) + if (!sourceFile) return [] + + const diagnostics = [ + ...this.languageService.getSyntacticDiagnostics(fileName), + ...this.languageService.getSemanticDiagnostics(fileName), + ] + const seen = new Set() + + return diagnostics.flatMap((diagnostic) => { + if (isBareModuleResolutionDiagnostic(diagnostic)) return [] + const from = Math.max(0, diagnostic.start ?? 0) + const to = Math.max(from + 1, from + (diagnostic.length ?? 1)) + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + const key = `${diagnostic.code}:${from}:${to}:${message}` + if (seen.has(key)) return [] + seen.add(key) + const location = sourceFile.getLineAndCharacterOfPosition( + Math.min(from, sourceFile.getFullText().length), + ) + return [{ + code: diagnostic.code, + severity: diagnosticSeverity(diagnostic.category), + message, + from, + to, + line: location.line + 1, + column: location.character, + }] + }) + } + + completions(path: string, position: number): TypeScriptCompletionResult | undefined { + const fileName = virtualPath(path) + const info = this.languageService.getCompletionsAtPosition(fileName, position, { + includeCompletionsForImportStatements: true, + includeCompletionsForModuleExports: true, + includeCompletionsWithInsertText: true, + }) + if (!info) return undefined + + const span = info.optionalReplacementSpan + return { + from: span?.start ?? position, + to: span ? span.start + span.length : position, + options: info.entries.slice(0, MAX_COMPLETIONS).map((entry) => ({ + label: entry.name, + ...(completionType(entry.kind) ? { type: completionType(entry.kind) } : {}), + ...(entry.kindModifiers ? { detail: entry.kindModifiers } : {}), + ...( + entry.insertText && !entry.isSnippet + ? { apply: entry.insertText } + : {} + ), + })), + } + } + + hover(path: string, position: number): TypeScriptHoverResult | undefined { + const info = this.languageService.getQuickInfoAtPosition(virtualPath(path), position) + if (!info) return undefined + return { + from: info.textSpan.start, + to: info.textSpan.start + info.textSpan.length, + signature: ts.displayPartsToString(info.displayParts), + documentation: ts.displayPartsToString(info.documentation), + } + } + + dispose(): void { + this.languageService.dispose() + } + + private hasFile(path: string): boolean { + const normalized = virtualPath(path) + return this.files.has(normalized) || this.libraries.has(libraryPath(path)) + } + + private readFile(path: string): string | undefined { + const normalized = virtualPath(path) + return this.files.get(normalized)?.content ?? this.libraries.get(libraryPath(path)) + } +} diff --git a/src/admin/pages/site/code-editor/typescriptProtocol.ts b/src/admin/pages/site/code-editor/typescriptProtocol.ts new file mode 100644 index 000000000..9f52c4da8 --- /dev/null +++ b/src/admin/pages/site/code-editor/typescriptProtocol.ts @@ -0,0 +1,106 @@ +import { Type, type Static } from '@sinclair/typebox' + +export const TypeScriptProjectFileSchema = Type.Object({ + path: Type.String(), + content: Type.String(), +}) + +export type TypeScriptProjectFile = Static + +const TypeScriptDiagnosticSeveritySchema = Type.Union([ + Type.Literal('error'), + Type.Literal('warning'), + Type.Literal('info'), +]) + +export const TypeScriptEditorDiagnosticSchema = Type.Object({ + code: Type.Number(), + severity: TypeScriptDiagnosticSeveritySchema, + message: Type.String(), + from: Type.Number(), + to: Type.Number(), + line: Type.Number(), + column: Type.Number(), +}) + +export type TypeScriptEditorDiagnostic = Static + +const TypeScriptCompletionOptionSchema = Type.Object({ + label: Type.String(), + type: Type.Optional(Type.String()), + detail: Type.Optional(Type.String()), + apply: Type.Optional(Type.String()), +}) + +const TypeScriptCompletionResultSchema = Type.Object({ + from: Type.Number(), + to: Type.Number(), + options: Type.Array(TypeScriptCompletionOptionSchema), +}) + +export type TypeScriptCompletionResult = Static + +const TypeScriptHoverResultSchema = Type.Object({ + from: Type.Number(), + to: Type.Number(), + signature: Type.String(), + documentation: Type.String(), +}) + +export type TypeScriptHoverResult = Static + +export const TypeScriptWorkerRequestSchema = Type.Union([ + Type.Object({ + type: Type.Literal('sync'), + files: Type.Array(TypeScriptProjectFileSchema), + }), + Type.Object({ + type: Type.Literal('update'), + path: Type.String(), + content: Type.String(), + }), + Type.Object({ + type: Type.Literal('diagnostics'), + requestId: Type.String(), + path: Type.String(), + }), + Type.Object({ + type: Type.Literal('completions'), + requestId: Type.String(), + path: Type.String(), + position: Type.Number(), + }), + Type.Object({ + type: Type.Literal('hover'), + requestId: Type.String(), + path: Type.String(), + position: Type.Number(), + }), +]) + +export type TypeScriptWorkerRequest = Static + +export const TypeScriptWorkerResponseSchema = Type.Union([ + Type.Object({ + type: Type.Literal('diagnostics'), + requestId: Type.String(), + diagnostics: Type.Array(TypeScriptEditorDiagnosticSchema), + }), + Type.Object({ + type: Type.Literal('completions'), + requestId: Type.String(), + result: Type.Optional(TypeScriptCompletionResultSchema), + }), + Type.Object({ + type: Type.Literal('hover'), + requestId: Type.String(), + result: Type.Optional(TypeScriptHoverResultSchema), + }), + Type.Object({ + type: Type.Literal('error'), + requestId: Type.String(), + message: Type.String(), + }), +]) + +export type TypeScriptWorkerResponse = Static diff --git a/src/admin/pages/site/code-editor/typescriptWorker.ts b/src/admin/pages/site/code-editor/typescriptWorker.ts new file mode 100644 index 000000000..f6f3dc2cb --- /dev/null +++ b/src/admin/pages/site/code-editor/typescriptWorker.ts @@ -0,0 +1,69 @@ +import { safeParseValue } from '@core/utils/typeboxHelpers' +import { getErrorMessage } from '@core/utils/errorMessage' +import { TypeScriptLanguageServiceEngine } from './typescriptLanguageServiceEngine' +import { + TypeScriptWorkerRequestSchema, + type TypeScriptWorkerResponse, +} from './typescriptProtocol' + +const libraries = import.meta.glob( + '/node_modules/typescript/lib/lib.*.d.ts', + { eager: true, import: 'default', query: '?raw' }, +) + +const engine = new TypeScriptLanguageServiceEngine(libraries) + +function respond(response: TypeScriptWorkerResponse): void { + self.postMessage(response) +} + +self.addEventListener('message', (event: MessageEvent) => { + const parsed = safeParseValue(TypeScriptWorkerRequestSchema, event.data) + if (!parsed.ok) { + console.warn('[typescript-worker] Ignored an invalid language-service request.') + return + } + + const request = parsed.value + try { + switch (request.type) { + case 'sync': + engine.sync(request.files) + break + case 'update': + engine.update(request.path, request.content) + break + case 'diagnostics': + respond({ + type: 'diagnostics', + requestId: request.requestId, + diagnostics: engine.diagnostics(request.path), + }) + break + case 'completions': + respond({ + type: 'completions', + requestId: request.requestId, + result: engine.completions(request.path, request.position), + }) + break + case 'hover': + respond({ + type: 'hover', + requestId: request.requestId, + result: engine.hover(request.path, request.position), + }) + break + } + } catch (error) { + if ('requestId' in request) { + respond({ + type: 'error', + requestId: request.requestId, + message: getErrorMessage(error, 'TypeScript language service failed'), + }) + return + } + console.error('[typescript-worker] Failed to synchronize the TypeScript project:', error) + } +}) diff --git a/src/admin/shared/dialogs/SiteCreateDialog/siteItemNames.ts b/src/admin/shared/dialogs/SiteCreateDialog/siteItemNames.ts index 79fb3f931..745b2f16f 100644 --- a/src/admin/shared/dialogs/SiteCreateDialog/siteItemNames.ts +++ b/src/admin/shared/dialogs/SiteCreateDialog/siteItemNames.ts @@ -25,6 +25,9 @@ export function buildStylePath(value: string) { } export function buildScriptPath(value: string) { - const name = ensureExtension(stripSitePrefix(value, 'src/scripts/'), '.ts') + const requestedName = stripSitePrefix(value, 'src/scripts/') + const name = /\.(?:[cm]?ts|tsx)$/.test(requestedName) + ? requestedName + : ensureExtension(requestedName, '.ts') return `src/scripts/${name}` } From 09e81a6f70f1ea781c10b55e982ff3cbbc8a0d15 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 12 Aug 2026 10:38:46 +0200 Subject: [PATCH 2/2] test(editor): isolate browser language worker --- ...pescript-language-worker-isolation.test.ts | 1 + .../code-editor/codeMirrorEditor.test.tsx | 11 ++++++++ .../typescriptLanguageService.test.ts | 25 ++++++++++++++++++- .../code-editor/typescriptLanguageClient.ts | 7 +++++- 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/__tests__/architecture/typescript-language-worker-isolation.test.ts b/src/__tests__/architecture/typescript-language-worker-isolation.test.ts index c7c8af08b..6b0b46794 100644 --- a/src/__tests__/architecture/typescript-language-worker-isolation.test.ts +++ b/src/__tests__/architecture/typescript-language-worker-isolation.test.ts @@ -14,6 +14,7 @@ describe('TypeScript language-service worker isolation', () => { const editor = editorFile('CodeMirrorEditor.tsx') expect(client).toContain("new Worker(new URL('./typescriptWorker.ts', import.meta.url)") + expect(client).toContain("typeof window.Worker === 'undefined'") expect(worker).toContain("from './typescriptLanguageServiceEngine'") expect(engine).toContain("from 'typescript'") expect(client).not.toContain("from 'typescript'") diff --git a/src/__tests__/code-editor/codeMirrorEditor.test.tsx b/src/__tests__/code-editor/codeMirrorEditor.test.tsx index b2da2f4e2..56afb16e4 100644 --- a/src/__tests__/code-editor/codeMirrorEditor.test.tsx +++ b/src/__tests__/code-editor/codeMirrorEditor.test.tsx @@ -102,6 +102,7 @@ describe('CodeMirrorEditor', () => { it('merges semantic TypeScript diagnostics from the lazy worker', async () => { const originalWorker = globalThis.Worker + const originalWindowWorker = window.Worker const reported: number[] = [] class DiagnosticWorker { @@ -134,6 +135,11 @@ describe('CodeMirrorEditor', () => { writable: true, value: DiagnosticWorker, }) + Object.defineProperty(window, 'Worker', { + configurable: true, + writable: true, + value: DiagnosticWorker, + }) try { render( @@ -164,6 +170,11 @@ describe('CodeMirrorEditor', () => { writable: true, value: originalWorker, }) + Object.defineProperty(window, 'Worker', { + configurable: true, + writable: true, + value: originalWindowWorker, + }) } }) }) diff --git a/src/__tests__/code-editor/typescriptLanguageService.test.ts b/src/__tests__/code-editor/typescriptLanguageService.test.ts index 818c15dce..d97899b9a 100644 --- a/src/__tests__/code-editor/typescriptLanguageService.test.ts +++ b/src/__tests__/code-editor/typescriptLanguageService.test.ts @@ -3,7 +3,10 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' import { TypeScriptLanguageServiceEngine } from '@site/code-editor/typescriptLanguageServiceEngine' -import { TypeScriptLanguageClient } from '@site/code-editor/typescriptLanguageClient' +import { + createTypeScriptLanguageClient, + TypeScriptLanguageClient, +} from '@site/code-editor/typescriptLanguageClient' import type { TypeScriptWorkerRequest, TypeScriptWorkerResponse, @@ -126,6 +129,26 @@ class FakeWorker { } describe('TypeScriptLanguageClient', () => { + it('does not treat Bun\'s process-global Worker as a browser worker', () => { + const originalWindowWorker = window.Worker + Object.defineProperty(window, 'Worker', { + configurable: true, + writable: true, + value: undefined, + }) + + try { + expect(typeof globalThis.Worker).toBe('function') + expect(createTypeScriptLanguageClient()).toBeNull() + } finally { + Object.defineProperty(window, 'Worker', { + configurable: true, + writable: true, + value: originalWindowWorker, + }) + } + }) + it('correlates validated worker responses and terminates cleanly', async () => { const worker = new FakeWorker() const client = new TypeScriptLanguageClient(() => worker) diff --git a/src/admin/pages/site/code-editor/typescriptLanguageClient.ts b/src/admin/pages/site/code-editor/typescriptLanguageClient.ts index db660730b..d716b8d19 100644 --- a/src/admin/pages/site/code-editor/typescriptLanguageClient.ts +++ b/src/admin/pages/site/code-editor/typescriptLanguageClient.ts @@ -127,6 +127,11 @@ export class TypeScriptLanguageClient { export function createTypeScriptLanguageClient( workerFactory?: TypeScriptWorkerFactory, ): TypeScriptLanguageClient | null { - if (!workerFactory && typeof Worker === 'undefined') return null + // The language service is browser-only. Bun also exposes a global Worker, + // including during DOM-emulated tests, but that is not the transport this + // client is designed to own and can outlive React cleanup at process exit. + if (!workerFactory && (typeof window === 'undefined' || typeof window.Worker === 'undefined')) { + return null + } return new TypeScriptLanguageClient(workerFactory) }