Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docs/e2e/feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/admin/siteItemNames.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
13 changes: 13 additions & 0 deletions src/__tests__/architecture/bundle-size-budgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
]

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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(client).toContain("typeof window.Worker === 'undefined'")
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,')
})
})
100 changes: 99 additions & 1 deletion src/__tests__/code-editor/codeMirrorEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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<HTMLAnchorElement>('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(
Expand Down Expand Up @@ -79,4 +99,82 @@ 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 originalWindowWorker = window.Worker
const reported: number[] = []

class DiagnosticWorker {
onmessage: ((event: MessageEvent<unknown>) => 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,
})
Object.defineProperty(window, 'Worker', {
configurable: true,
writable: true,
value: DiagnosticWorker,
})

try {
render(
<CodeMirrorEditor
docKey="script-typed"
value="const title: number = document.title"
language="ts"
filePath="src/scripts/typed.ts"
projectFiles={[{
path: 'src/scripts/typed.ts',
content: 'const title: number = document.title',
}]}
onChange={() => undefined}
onTypeScriptDiagnosticsChange={(diagnostics) => reported.push(diagnostics.length)}
/>,
)

await waitFor(() => {
const editor = document.querySelector<HTMLElement>('.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,
})
Object.defineProperty(window, 'Worker', {
configurable: true,
writable: true,
value: originalWindowWorker,
})
}
})
})
29 changes: 29 additions & 0 deletions src/__tests__/code-editor/scriptSettingsPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CodeEditorPanel runtimeValidation={{ status: 'valid', diagnostics }} />)

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(<CodeEditorPanel />)

Expand Down
Loading
Loading