diff --git a/bun.lock b/bun.lock index 7cbc3dd93..3a0333828 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", "@codemirror/state": "^6.6.0", "@dnd-kit/core": "^6.3.1", "@fontsource-variable/inter": "^5.2.8", diff --git a/package.json b/package.json index fe9a50d9b..737452dfe 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.5", "@codemirror/state": "^6.6.0", "@dnd-kit/core": "^6.3.1", "@fontsource-variable/inter": "^5.2.8", diff --git a/server/handlers/cms/publish.ts b/server/handlers/cms/publish.ts index 8b7934c31..02b1b4a6b 100644 --- a/server/handlers/cms/publish.ts +++ b/server/handlers/cms/publish.ts @@ -23,6 +23,7 @@ import { requireCapability, requireStepUp } from '../../auth/authz' import { createAuditEvent } from '../../repositories/audit' import { getDraftPublishStatus } from '../../repositories/publish' import { publishDraftSite } from '../../publish/publishSite' +import { RuntimeScriptBuildError } from '../../publish/runtime/buildError' import { jsonResponse, methodNotAllowed } from '../../http' import type { CmsHandlerOptions } from './shared' import { requestAuditContext } from './shared' @@ -43,7 +44,15 @@ export async function handlePublishRoutes( // publishDraftSite flushes the collab relay itself (see publishFlush.ts), // so the snapshot includes edits still inside the debounce window. - const result = await publishDraftSite(db, user.id, options.uploadsDir) + let result: Awaited> + try { + result = await publishDraftSite(db, user.id, options.uploadsDir) + } catch (err) { + if (err instanceof RuntimeScriptBuildError) { + return jsonResponse({ error: err.message }, { status: 422 }) + } + throw err + } await createAuditEvent(db, { actorUserId: user.id, action: 'publish', diff --git a/server/handlers/cms/runtime.ts b/server/handlers/cms/runtime.ts index 7716892bf..fd6cdbd19 100644 --- a/server/handlers/cms/runtime.ts +++ b/server/handlers/cms/runtime.ts @@ -12,6 +12,9 @@ * iframe. Read-floor capability is correct here: anyone who can * open the site editor can preview the draft they posted. * + * POST /admin/api/cms/runtime/validate — compile every enabled draft + * script and return file/line diagnostics without publishing. + * * Both endpoints accept the draft site in the request body rather than * loading the persisted draft — preview must reflect unsaved edits. */ @@ -21,6 +24,7 @@ import { resolveSiteDependencyLock } from '../../publish/runtime/dependencyResol import { ensureRuntimeDependencyCache } from '../../publish/runtime/dependencyCache' import { buildRuntimePackageImportmap } from '../../publish/runtime/packageImportmap' import { buildRuntimePreviewDocument } from '../../publish/runtime/previewRuntime' +import { buildSiteRuntimeScripts } from '../../publish/runtime/bundleScripts' import { validateSite, validatePages, validateVisualComponents, SiteValidationError } from '@core/persistence/validate' import { isSafePackageName } from '@core/site-dependencies/packageNames' import type { SitePackageJson } from '@core/site-dependencies/manifest' @@ -82,6 +86,29 @@ function runtimeRequestPackageJson(raw: unknown): SitePackageJson { } } +function runtimeRequestSite(raw: Record): SiteDocument { + const shell: SiteShell = validateSite(raw) + const rawPages = Array.isArray(raw.pages) ? raw.pages : [] + const rawVCs = Array.isArray(raw.visualComponents) ? raw.visualComponents : [] + const parsedVCs = rawVCs.flatMap((value) => { + const visualComponent = parseVisualComponent(value) + return visualComponent ? [visualComponent] : [] + }) + const visualComponents = validateVisualComponents(parsedVCs) + const pages = validatePages(shell, rawPages, visualComponents, { + storedVcIds: new Set(parsedVCs.map((visualComponent) => visualComponent.id)), + }) + // Saved layouts are editor-only; script compilation never reads them. + return { ...shell, pages, visualComponents, layouts: [] } +} + +async function runtimeDependencyCache(site: SiteDocument) { + const runtime = normalizeSiteRuntimeConfig(site.runtime) + return Object.keys(runtime.dependencyLock.packages).length > 0 + ? await ensureRuntimeDependencyCache(runtime.dependencyLock) + : undefined +} + export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise { const url = new URL(req.url) @@ -131,6 +158,33 @@ export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise { - const vc = parseVisualComponent(raw) - return vc ? [vc] : [] - }) - const visualComponents = validateVisualComponents(parsedVCs) - // Strip page VC-refs only against ids present in the submitted roster, so - // a deduped/de-cycled VC does not strip authored slot content from the - // preview render (ISS-016). - const pages = validatePages(shell, rawPages, visualComponents, { - storedVcIds: new Set(parsedVCs.map((vc) => vc.id)), - }) - // Saved layouts are editor-only; preview rendering ignores them. - const site: SiteDocument = { ...shell, pages, visualComponents, layouts: [] } + const site = runtimeRequestSite(body.site) const page = resolvePreviewPage(site, pageId) if (!page) return jsonResponse({ error: 'Page not found' }, { status: 404 }) - const runtime = normalizeSiteRuntimeConfig(site.runtime) - const dependencyCache = Object.keys(runtime.dependencyLock.packages).length > 0 - ? await ensureRuntimeDependencyCache(runtime.dependencyLock) - : undefined const preview = await buildRuntimePreviewDocument({ site, page, registry, assetBasePath: '/_instatic/preview/runtime/', - dependencyCache, + dependencyCache: await runtimeDependencyCache(site), breakpointId, templateContext, db, diff --git a/server/publish/publishSite.ts b/server/publish/publishSite.ts index 0c39dd896..cf4901610 100644 --- a/server/publish/publishSite.ts +++ b/server/publish/publishSite.ts @@ -32,6 +32,7 @@ import { type PublishedPageVersionWrite, } from '../repositories/publish' import { buildSiteRuntimeScripts } from './runtime/bundleScripts' +import { RuntimeScriptBuildError } from './runtime/buildError' import { ensureRuntimeDependencyCache } from './runtime/dependencyCache' import { buildRuntimePackageImportmap, @@ -150,7 +151,7 @@ async function publishDraftSiteLocked( }) const runtimeErrors = runtimeBuild.diagnostics.filter((d) => d.severity === 'error') if (runtimeErrors.length > 0) { - throw new Error(`runtime build failed: ${runtimeErrors.map((d) => d.message).join('; ')}`) + throw new RuntimeScriptBuildError(page, runtimeErrors) } const snapshot = createSnapshot( diff --git a/server/publish/runtime/buildError.ts b/server/publish/runtime/buildError.ts new file mode 100644 index 000000000..072d31973 --- /dev/null +++ b/server/publish/runtime/buildError.ts @@ -0,0 +1,25 @@ +import type { Page } from '@core/page-tree' +import type { SiteRuntimeDiagnostic } from '@core/site-runtime' + +function formatDiagnostic(diagnostic: SiteRuntimeDiagnostic): string { + const line = diagnostic.line + const column = diagnostic.column + const location = diagnostic.path + ? `${diagnostic.path}${line === undefined ? '' : `:${line}${column === undefined ? '' : `:${column + 1}`}`}` + : null + return location ? `${location} — ${diagnostic.message}` : diagnostic.message +} + +export class RuntimeScriptBuildError extends Error { + readonly diagnostics: SiteRuntimeDiagnostic[] + readonly pageId: string + + constructor(page: Page, diagnostics: SiteRuntimeDiagnostic[]) { + const details = diagnostics.map(formatDiagnostic).join('; ') + const pageLabel = page.title || page.slug || page.id + super(`Runtime script build failed for page "${pageLabel}": ${details}`) + this.name = 'RuntimeScriptBuildError' + this.diagnostics = diagnostics + this.pageId = page.id + } +} diff --git a/server/publish/runtime/bundleScripts.ts b/server/publish/runtime/bundleScripts.ts index 2196eeb85..8f513e19f 100644 --- a/server/publish/runtime/bundleScripts.ts +++ b/server/publish/runtime/bundleScripts.ts @@ -1,9 +1,10 @@ -import { relative, sep } from 'node:path' +import { isAbsolute, relative, sep } from 'node:path' import * as esbuild from 'esbuild' import type { Page, SiteDocument } from '@core/page-tree' import { analyzeRuntimeScriptImports, collectRuntimeScripts, + DEFAULT_SCRIPT_RUNTIME_CONFIG, normalizeSiteRuntimeConfig, } from '@core/site-runtime' import type { @@ -34,9 +35,8 @@ export interface SiteRuntimeBuildResult { diagnostics: SiteRuntimeDiagnostic[] } -export interface BuildSiteRuntimeScriptsInput { +interface BuildSiteRuntimeScriptsBaseInput { site: SiteDocument - page: Page target: SiteRuntimeTarget assetBasePath: string dependencyCache?: Pick @@ -45,6 +45,18 @@ export interface BuildSiteRuntimeScriptsInput { bundleTimeoutMs?: number } +export type BuildSiteRuntimeScriptsInput = BuildSiteRuntimeScriptsBaseInput & ( + | { + page: Page + scriptSelection?: 'page' + } + | { + /** Validate every enabled script, independent of its page scope. */ + scriptSelection: 'all-enabled' + page?: never + } +) + /** * Hard upper bound on the time a single esbuild invocation may run. * Pathological imports or very large script trees should fail fast rather @@ -73,6 +85,22 @@ function scriptFormat(entry: RuntimeScriptEntry): 'module' | 'classic' { return entry.config.format === 'classic' ? 'classic' : 'module' } +function collectAllEnabledRuntimeScripts( + site: SiteDocument, + runtime: ReturnType, +): RuntimeScriptEntry[] { + const scripts: RuntimeScriptEntry[] = [] + for (const file of site.files) { + if (file.type !== 'script') continue + const config = runtime.scripts[file.id] ?? { ...DEFAULT_SCRIPT_RUNTIME_CONFIG } + if (config.enabled) scripts.push({ file, config }) + } + return scripts.sort((a, b) => { + const priority = a.config.priority - b.config.priority + return priority || a.file.path.localeCompare(b.file.path) + }) +} + function safeOutputFileName(path: string): string { const base = path.split('/').pop() ?? 'script.js' const safe = base.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') @@ -136,7 +164,31 @@ function emptyRuntimeBuild(diagnostics: SiteRuntimeDiagnostic[] = []): SiteRunti } } -function esbuildDiagnostics(error: unknown): SiteRuntimeDiagnostic[] { +function diagnosticPathInsideWorkspace(path: string, rootDir: string): string | undefined { + const relativePath = isAbsolute(path) ? relative(rootDir, path) : path + const normalized = toPosixPath(relativePath).replace(/^\.\//, '') + if (!normalized || normalized === '..' || normalized.startsWith('../') || normalized.startsWith('/')) { + return undefined + } + return normalized +} + +function esbuildDiagnostics( + error: unknown, + site: SiteDocument, + rootDir: string, + entryPointByFileId: Map, +): SiteRuntimeDiagnostic[] { + const authoredFileById = new Map(site.files.map((file) => [file.id, file])) + const authoredFileByWorkspacePath = new Map( + [...entryPointByFileId.entries()].flatMap(([fileId, absolutePath]) => { + const file = authoredFileById.get(fileId) + return file + ? [[toPosixPath(relative(rootDir, absolutePath)), file] as const] + : [] + }), + ) + if ( error && typeof error === 'object' && @@ -144,23 +196,88 @@ function esbuildDiagnostics(error: unknown): SiteRuntimeDiagnostic[] { Array.isArray((error as { errors: unknown }).errors) ) { return (error as { errors: Array<{ text?: string; location?: { file?: string; line?: number; column?: number } }> }).errors + .map((item) => { + const workspacePath = item.location?.file + ? diagnosticPathInsideWorkspace(item.location.file, rootDir) + : undefined + const authoredFile = workspacePath + ? authoredFileByWorkspacePath.get(workspacePath) + : undefined + return { + code: 'runtime-bundle-error', + severity: 'error' as const, + message: item.text ?? 'Runtime script bundle failed', + ...(authoredFile ? { fileId: authoredFile.id, path: authoredFile.path } : {}), + ...(!authoredFile && workspacePath ? { path: workspacePath } : {}), + ...(item.location?.line !== undefined ? { line: item.location.line } : {}), + ...(item.location?.column !== undefined ? { column: item.location.column } : {}), + } + }) + } + + return [{ + code: 'runtime-bundle-error', + severity: 'error', + message: error instanceof Error ? error.message : 'Runtime script bundle failed', + }] +} + +function classicScriptDiagnostics( + error: unknown, + script: RuntimeScriptEntry, +): SiteRuntimeDiagnostic[] { + if ( + error && + typeof error === 'object' && + 'errors' in error && + Array.isArray((error as { errors: unknown }).errors) + ) { + return (error as { errors: Array<{ text?: string; location?: { line?: number; column?: number } }> }).errors .map((item) => ({ code: 'runtime-bundle-error', severity: 'error' as const, - message: item.text ?? 'Runtime script bundle failed', - path: item.location?.file, - line: item.location?.line, - column: item.location?.column, + message: item.text ?? 'Runtime script syntax check failed', + fileId: script.file.id, + path: script.file.path, + ...(item.location?.line !== undefined ? { line: item.location.line } : {}), + ...(item.location?.column !== undefined ? { column: item.location.column } : {}), })) } return [{ code: 'runtime-bundle-error', severity: 'error', - message: error instanceof Error ? error.message : 'Runtime script bundle failed', + message: error instanceof Error ? error.message : 'Runtime script syntax check failed', + fileId: script.file.id, + path: script.file.path, }] } +async function validateClassicRuntimeScripts( + scripts: RuntimeScriptEntry[], +): Promise { + const diagnosticsByScript = await Promise.all(scripts.map(async (script) => { + try { + // Classic scripts are emitted byte-for-byte so they retain browser + // globals. Parse them separately to catch syntax errors without + // changing their published output. + await esbuild.transform(script.file.content ?? '', { + loader: 'js', + logLevel: 'silent', + target: ['es2020'], + }) + return [] + } catch (error) { + return classicScriptDiagnostics(error, script) + } + })) + const diagnostics: SiteRuntimeDiagnostic[] = [] + for (const scriptDiagnostics of diagnosticsByScript) { + diagnostics.push(...scriptDiagnostics) + } + return diagnostics +} + function selectedScriptByEntryPoint( selectedScripts: RuntimeScriptEntry[], entryPointByFileId: Map, @@ -179,32 +296,43 @@ export async function buildSiteRuntimeScripts( input: BuildSiteRuntimeScriptsInput, ): Promise { const runtime = normalizeSiteRuntimeConfig(input.site.runtime) - const selectedScripts = collectRuntimeScripts({ - files: input.site.files, - runtime, - page: input.page, - target: input.target, - }) + let selectedScripts: RuntimeScriptEntry[] + if (input.scriptSelection === 'all-enabled') { + selectedScripts = collectAllEnabledRuntimeScripts(input.site, runtime) + } else { + selectedScripts = collectRuntimeScripts({ + files: input.site.files, + runtime, + page: input.page, + target: input.target, + }) + } if (selectedScripts.length === 0) return emptyRuntimeBuild() const moduleScripts = selectedScripts.filter((entry) => scriptFormat(entry) === 'module') const classicScripts = selectedScripts.filter((entry) => scriptFormat(entry) === 'classic') const classicBuild = buildClassicRuntimeFiles(classicScripts, input.assetBasePath) + const classicDiagnostics = await validateClassicRuntimeScripts(classicScripts) const packageJson = clonePackageJson(input.site.packageJson ?? DEFAULT_SITE_PACKAGE_JSON) const importAnalysis = analyzeRuntimeScriptImports( moduleScripts.map((entry) => entry.file), packageJson, ) + const staticDiagnostics = [ + ...classicDiagnostics, + ...importAnalysis.diagnostics, + ] const blockingDiagnostics = importAnalysis.diagnostics.filter((diagnostic) => diagnostic.severity === 'error') - if (blockingDiagnostics.length > 0) return emptyRuntimeBuild(importAnalysis.diagnostics) + if (blockingDiagnostics.length > 0) return emptyRuntimeBuild(staticDiagnostics) if (moduleScripts.length === 0) { + if (classicDiagnostics.length > 0) return emptyRuntimeBuild(staticDiagnostics) return { files: classicBuild.files, runtimeAssets: { scripts: classicBuild.assets }, - diagnostics: importAnalysis.diagnostics, + diagnostics: staticDiagnostics, } } @@ -215,10 +343,11 @@ export async function buildSiteRuntimeScripts( .filter((entryPoint): entryPoint is string => Boolean(entryPoint)) if (entryPoints.length === 0) { + if (classicDiagnostics.length > 0) return emptyRuntimeBuild(staticDiagnostics) return { files: classicBuild.files, runtimeAssets: { scripts: classicBuild.assets }, - diagnostics: importAnalysis.diagnostics, + diagnostics: staticDiagnostics, } } @@ -326,13 +455,18 @@ export async function buildSiteRuntimeScripts( const scripts = [...moduleAssetScripts, ...classicBuild.assets] .sort((a, b) => a.priority - b.priority || a.src.localeCompare(b.src)) + if (classicDiagnostics.length > 0) return emptyRuntimeBuild(staticDiagnostics) + return { files: [...files, ...classicBuild.files], runtimeAssets: { scripts }, - diagnostics: importAnalysis.diagnostics, + diagnostics: staticDiagnostics, } } catch (error) { - return emptyRuntimeBuild([...importAnalysis.diagnostics, ...esbuildDiagnostics(error)]) + return emptyRuntimeBuild([ + ...staticDiagnostics, + ...esbuildDiagnostics(error, input.site, workspace.rootDir, workspace.entryPointByFileId), + ]) } finally { await workspace.cleanup() } diff --git a/src/__tests__/code-editor/codeMirrorEditor.test.tsx b/src/__tests__/code-editor/codeMirrorEditor.test.tsx index f8a9215fb..2d6bc60eb 100644 --- a/src/__tests__/code-editor/codeMirrorEditor.test.tsx +++ b/src/__tests__/code-editor/codeMirrorEditor.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test' import React from 'react' import { cleanup, render } from '@testing-library/react' import { EditorView } from '@codemirror/view' +import { diagnosticCount } from '@codemirror/lint' import CodeMirrorEditor from '@site/code-editor/CodeMirrorEditor' afterEach(cleanup) @@ -40,4 +41,42 @@ describe('CodeMirrorEditor', () => { expect(changes).toEqual(['
New
']) }) + + it('updates compiler diagnostics without remounting the editor', async () => { + const { rerender } = render( + undefined} + diagnostics={[]} + />, + ) + await nextFrame() + + const editor = document.querySelector('.cm-editor') + const view = EditorView.findFromDOM(editor!)! + expect(diagnosticCount(view.state)).toBe(0) + + rerender( + undefined} + diagnostics={[{ + code: 'runtime-bundle-error', + severity: 'error', + message: 'Expected identifier', + line: 1, + column: 6, + }]} + />, + ) + await nextFrame() + + expect(EditorView.findFromDOM(editor!)).toBe(view) + expect(diagnosticCount(view.state)).toBe(1) + expect(document.querySelector('.cm-lint-marker-error')).toBeTruthy() + }) }) diff --git a/src/__tests__/persistence/cmsRuntimeClient.test.ts b/src/__tests__/persistence/cmsRuntimeClient.test.ts index 0eb135683..5e5010ffc 100644 --- a/src/__tests__/persistence/cmsRuntimeClient.test.ts +++ b/src/__tests__/persistence/cmsRuntimeClient.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'bun:test' import { ApiError } from '@core/http' -import { buildCmsRuntimePreview, resolveCmsRuntimeDependencies } from '@core/persistence' +import { + buildCmsRuntimePreview, + resolveCmsRuntimeDependencies, + validateCmsRuntimeScripts, +} from '@core/persistence' describe('CMS runtime client', () => { it('posts dependency manifests to the runtime resolve endpoint', async () => { @@ -140,4 +144,37 @@ describe('CMS runtime client', () => { templateContext: { entryStack: [] }, })) }) + + it('posts draft sites to the runtime validation endpoint', async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + const controller = new AbortController() + const diagnostics = await validateCmsRuntimeScripts( + { id: 'site_1', files: [] }, + { + signal: controller.signal, + fetchImpl: async (input, init) => { + calls.push({ input, init }) + return new Response(JSON.stringify({ diagnostics: [{ + code: 'runtime-bundle-error', + severity: 'error', + message: 'Expected identifier', + fileId: 'script_1', + path: 'src/scripts/test.ts', + line: 3, + column: 4, + }] }), { status: 200 }) + }, + }, + ) + + expect(diagnostics[0]).toMatchObject({ fileId: 'script_1', line: 3 }) + expect(calls[0]).toMatchObject({ + input: '/admin/api/cms/runtime/validate', + init: { method: 'POST', credentials: 'include' }, + }) + expect(calls[0].init?.signal).toBe(controller.signal) + expect(calls[0].init?.body).toBe(JSON.stringify({ + site: { id: 'site_1', files: [] }, + })) + }) }) diff --git a/src/__tests__/server/cmsPublish.test.ts b/src/__tests__/server/cmsPublish.test.ts index 8312c3f63..1aa4e6b45 100644 --- a/src/__tests__/server/cmsPublish.test.ts +++ b/src/__tests__/server/cmsPublish.test.ts @@ -426,4 +426,42 @@ describe('CMS publishing', () => { expect(published?.runtimeAssets?.scripts).toHaveLength(1) expect(published?.runtimeAssets?.scripts[0].src).toBe(state.runtimeAssets[0].public_path) }) + + it('rejects invalid authored runtime scripts with their file and location before writing a publish', async () => { + const { state, db } = createPublishFakeDb() + const shell = makeSiteShell({ + files: [ + { + id: 'forgotten-test-script', + path: 'src/scripts/forgotten-test.ts', + type: 'script', + content: `const value from 'broken'`, + createdAt: 1, + updatedAt: 1, + }, + ], + runtime: normalizeSiteRuntimeConfig({ + scripts: { + 'forgotten-test-script': { + placement: 'body-end', + priority: 10, + }, + }, + }), + }) + await saveDraftSite(db, shell) + const page = makeHomePage('Runtime page') + await createDataRow(db, { + id: page.id, + tableId: 'pages', + cells: pageToCells(page), + slug: page.slug, + }, 'admin_1') + + await expect(publishDraftSite(db, 'admin_1')).rejects.toThrow( + 'Runtime script build failed for page "Home": src/scripts/forgotten-test.ts:1:', + ) + expect(state.siteSnapshots).toEqual([]) + expect(state.dataRowVersions).toEqual([]) + }) }) diff --git a/src/__tests__/server/publishRuntimeErrorResponse.test.ts b/src/__tests__/server/publishRuntimeErrorResponse.test.ts new file mode 100644 index 000000000..65cc023a9 --- /dev/null +++ b/src/__tests__/server/publishRuntimeErrorResponse.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'bun:test' +import type { SiteShell } from '@core/page-tree' +import { normalizeSiteRuntimeConfig } from '@core/site-runtime' +import { saveDraftSite } from '../../../server/repositories/site' +import { + createCapabilityTestHarness, + readJson, +} from '../helpers/capabilityHarness' + +describe('publish runtime validation response', () => { + it('returns live compiler diagnostics without attempting a publish', async () => { + const harness = await createCapabilityTestHarness() + try { + const cookie = await harness.setupOwner() + const siteResponse = await harness.cms('/admin/api/cms/site', { cookie }) + const { site } = await readJson<{ site: SiteShell }>(siteResponse) + const draft = { + ...site, + pages: [], + visualComponents: [], + files: [ + ...site.files, + { + id: 'live-broken-script', + path: 'src/scripts/live-broken.ts', + type: 'script' as const, + content: `const value from 'broken'`, + createdAt: 1, + updatedAt: 1, + }, + ], + runtime: normalizeSiteRuntimeConfig({ + ...site.runtime, + scripts: { + ...site.runtime?.scripts, + 'live-broken-script': { placement: 'body-end' }, + }, + }), + } + + const response = await harness.cms('/admin/api/cms/runtime/validate', { + method: 'POST', + cookie, + json: { site: draft }, + }) + + expect(response.status).toBe(200) + const body = await readJson<{ diagnostics: Array> }>(response) + expect(body.diagnostics).toEqual([ + expect.objectContaining({ + fileId: 'live-broken-script', + path: 'src/scripts/live-broken.ts', + line: 1, + severity: 'error', + message: 'Expected ";" but found "from"', + }), + ]) + } finally { + await harness.cleanup() + } + }, 15_000) + + it('returns an actionable 422 for an invalid authored runtime script', async () => { + const harness = await createCapabilityTestHarness() + try { + const cookie = await harness.setupOwner() + const siteResponse = await harness.cms('/admin/api/cms/site', { cookie }) + expect(siteResponse.status).toBe(200) + const { site } = await readJson<{ site: SiteShell }>(siteResponse) + + await saveDraftSite(harness.db, { + ...site, + files: [ + ...site.files, + { + id: 'forgotten-test-script', + path: 'src/scripts/forgotten-test.ts', + type: 'script', + content: `const value from 'broken'`, + createdAt: 1, + updatedAt: 1, + }, + ], + runtime: normalizeSiteRuntimeConfig({ + ...site.runtime, + scripts: { + ...site.runtime?.scripts, + 'forgotten-test-script': { + placement: 'body-end', + priority: 10, + }, + }, + }), + }) + + const response = await harness.cms('/admin/api/cms/publish', { + method: 'POST', + cookie, + }) + expect(response.status).toBe(422) + const body = await readJson<{ error: string }>(response) + expect(body.error).toContain( + 'src/scripts/forgotten-test.ts:1:', + ) + expect(body.error).toContain('Expected ";" but found "from"') + } finally { + await harness.cleanup() + } + }, 15_000) +}) diff --git a/src/__tests__/server/siteRuntimeBuild.test.ts b/src/__tests__/server/siteRuntimeBuild.test.ts index c19456b62..a145dddc2 100644 --- a/src/__tests__/server/siteRuntimeBuild.test.ts +++ b/src/__tests__/server/siteRuntimeBuild.test.ts @@ -113,6 +113,171 @@ describe('site runtime build', () => { ]) }) + it('maps syntax diagnostics back to the authored script and source location', async () => { + const site = runtimeSite({ + files: [ + { + id: 'broken-script', + path: 'src/scripts/broken.ts', + type: 'script', + content: `const value from 'broken'`, + createdAt: 1, + updatedAt: 1, + }, + ], + runtime: normalizeSiteRuntimeConfig({ + scripts: { + 'broken-script': { + placement: 'head', + priority: 10, + }, + }, + }), + }) + + const result = await buildSiteRuntimeScripts({ + site, + page, + target: 'publish', + assetBasePath: '/_instatic/assets/runtime/', + }) + + expect(result.files).toEqual([]) + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + code: 'runtime-bundle-error', + severity: 'error', + message: 'Expected ";" but found "from"', + fileId: 'broken-script', + path: 'src/scripts/broken.ts', + line: 1, + column: expect.any(Number), + }), + ]) + }) + + it('validates enabled scripts even when their scope excludes the current page', async () => { + const site = runtimeSite({ + files: [{ + id: 'other-page-script', + path: 'src/scripts/other-page.ts', + type: 'script', + content: `const value from 'broken'`, + createdAt: 1, + updatedAt: 1, + }], + runtime: normalizeSiteRuntimeConfig({ + scripts: { + 'other-page-script': { + scope: { type: 'pages', pageIds: ['another-page'] }, + }, + }, + }), + }) + + const result = await buildSiteRuntimeScripts({ + site, + target: 'publish', + assetBasePath: '/_instatic/runtime-validation/', + scriptSelection: 'all-enabled', + }) + + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + fileId: 'other-page-script', + path: 'src/scripts/other-page.ts', + line: 1, + severity: 'error', + }), + ]) + }) + + it('syntax-checks classic scripts while preserving their raw published output', async () => { + const site = runtimeSite({ + files: [{ + id: 'classic-script', + path: 'src/scripts/classic.js', + type: 'script', + content: 'window.classic = ;', + createdAt: 1, + updatedAt: 1, + }], + runtime: normalizeSiteRuntimeConfig({ + scripts: { + 'classic-script': { format: 'classic' }, + }, + }), + }) + + const result = await buildSiteRuntimeScripts({ + site, + page, + target: 'publish', + assetBasePath: '/_instatic/assets/runtime/', + }) + + expect(result.files).toEqual([]) + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + fileId: 'classic-script', + path: 'src/scripts/classic.js', + line: 1, + severity: 'error', + }), + ]) + }) + + it('reports classic and module syntax errors in the same validation pass', async () => { + const site = runtimeSite({ + files: [ + { + id: 'classic-script', + path: 'src/scripts/classic.js', + type: 'script', + content: 'window.classic = ;', + createdAt: 1, + updatedAt: 1, + }, + { + id: 'module-script', + path: 'src/scripts/module.ts', + type: 'script', + content: `const value from 'broken'`, + createdAt: 1, + updatedAt: 1, + }, + ], + runtime: normalizeSiteRuntimeConfig({ + scripts: { + 'classic-script': { format: 'classic' }, + 'module-script': { format: 'module' }, + }, + }), + }) + + const result = await buildSiteRuntimeScripts({ + site, + target: 'publish', + assetBasePath: '/_instatic/runtime-validation/', + scriptSelection: 'all-enabled', + }) + + expect(result.files).toEqual([]) + expect(result.runtimeAssets.scripts).toEqual([]) + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + fileId: 'classic-script', + path: 'src/scripts/classic.js', + severity: 'error', + }), + expect.objectContaining({ + fileId: 'module-script', + path: 'src/scripts/module.ts', + severity: 'error', + }), + ])) + }) + it('builds a preview document with the same runtime assets used by publish rendering', async () => { const result = await buildRuntimePreviewDocument({ site: runtimeSite(), diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx index 7c214af44..faa2f9642 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasEditorBody.tsx @@ -21,6 +21,7 @@ import { Dialog } from '@ui/components/Dialog' import { Button } from '@ui/components/Button' import { cn } from '@ui/cn' import styles from './AdminCanvasLayout.module.css' +import type { RuntimeScriptValidationState } from '@admin/pages/site/hooks/useRuntimeScriptDiagnostics' // Register the editor-only runtime graph from the lazy body, not the route // shell. The toolbar/chrome can paint without block definitions or loop @@ -37,6 +38,7 @@ interface AdminCanvasEditorBodyProps { canSaveSite: boolean canUseAiChat: boolean loadError: string | null + runtimeValidation: RuntimeScriptValidationState } export function AdminCanvasEditorBody({ @@ -44,6 +46,7 @@ export function AdminCanvasEditorBody({ canSaveSite, canUseAiChat, loadError, + runtimeValidation, }: AdminCanvasEditorBodyProps) { // Keep `siteRuntime.dependencyLock` in lockstep with `packageJson` while // the editor body is open. @@ -127,7 +130,7 @@ export function AdminCanvasEditorBody({ canvas stage. The panel itself is small chrome; the heavy CodeMirror 6 bundle (~600 kB) is lazy-loaded inside the panel only when the user opens a text file. */} - + {/* Naming step for "Save as layout" / saved-layout rename. Renders null until a layoutNameDialogRequest is set on the ui slice. */} diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 75dcb0bdd..5627be739 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -43,6 +43,10 @@ import { PublishButton } from '@admin/pages/site/toolbar/PublishButton' import { PeerAvatarStack } from '@admin/pages/site/toolbar/PeerAvatarStack' import { useEditorAppearancePreferences } from '@admin/pages/site/preferences/editorPreferences' import { usePersistence } from '@admin/pages/site/hooks/usePersistence' +import { + useRuntimeScriptDiagnostics, + type RuntimeScriptValidationState, +} from '@admin/pages/site/hooks/useRuntimeScriptDiagnostics' import { useSiteEditorUrlSync } from '@admin/pages/site/hooks/useSiteEditorUrlSync' import { useEditorLayoutPersistence } from '@admin/pages/site/hooks/useEditorLayoutPersistence' import { useEditorStore } from '@admin/pages/site/store/store' @@ -77,6 +81,7 @@ interface AdminCanvasEditorBodyProps { canSaveSite: boolean canUseAiChat: boolean loadError: string | null + runtimeValidation: RuntimeScriptValidationState } const AdminCanvasEditorBody = prewarmedLazy( @@ -163,6 +168,7 @@ export function AdminCanvasLayout() { // Boot the document lifecycle: HTTP load for first paint, then the collab // provider — every edit streams live and the server relay persists. const persistence = usePersistence('default', cmsAdapter, { enabled: true }) + const runtimeValidation = useRuntimeScriptDiagnostics() // Keep the open page in lockstep with the URL: consume `?page=` on // load, and mirror the active page's slug back into the address bar so it's // directly linkable. @@ -223,7 +229,12 @@ export function AdminCanvasLayout() { <> - + )} /> @@ -240,6 +251,7 @@ export function AdminCanvasLayout() { canSaveSite={canSaveSite} canUseAiChat={canUseAgent} loadError={loadError} + runtimeValidation={runtimeValidation} /> ) : ( diff --git a/src/admin/pages/site/code-editor/CodeEditorPanel.module.css b/src/admin/pages/site/code-editor/CodeEditorPanel.module.css index 50cda5340..2048e9e26 100644 --- a/src/admin/pages/site/code-editor/CodeEditorPanel.module.css +++ b/src/admin/pages/site/code-editor/CodeEditorPanel.module.css @@ -74,6 +74,93 @@ overflow: hidden; } +.problems { + flex: 0 0 auto; + max-height: 132px; + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-muted); + background: var(--bg-surface-2); +} + +.problemsHeader { + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-s); + padding: 0 var(--space-m); + border-bottom: 1px solid var(--border-muted); +} + +.problemsTitle, +.problemsStatus { + font-size: var(--text-xs); + line-height: 1.2; +} + +.problemsTitle { + color: var(--text-subtle); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.problemsStatus { + color: var(--text-muted); +} + +.problemsStatusError { + color: var(--danger-text); +} + +.problemsList { + min-height: 0; + margin: 0; + padding: var(--space-xs) 0; + overflow: auto; + list-style: none; +} + +.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; + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.4; +} + +.problem[data-severity='error'] { + border-left-color: var(--danger); +} + +.problem[data-severity='warning'] { + border-left-color: var(--warning); +} + +.problemLocation { + overflow: hidden; + color: var(--text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.problemMessage { + color: var(--text); + overflow-wrap: anywhere; +} + +.problemsEmpty { + margin: 0; + padding: var(--space-s) var(--space-m); + color: var(--text-muted); + font-size: var(--text-xs); + line-height: 1.4; +} + @media (max-width: 760px) { .editorWorkspace { flex-direction: column; diff --git a/src/admin/pages/site/code-editor/CodeEditorPanel.tsx b/src/admin/pages/site/code-editor/CodeEditorPanel.tsx index 7e3dcb55f..9e8ca775b 100644 --- a/src/admin/pages/site/code-editor/CodeEditorPanel.tsx +++ b/src/admin/pages/site/code-editor/CodeEditorPanel.tsx @@ -36,7 +36,9 @@ import { StyleSettingsPane } from './StyleSettingsPane' import { EmptyState } from '@ui/components/EmptyState' 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 { RuntimeScriptValidationState } from '@site/hooks/useRuntimeScriptDiagnostics' import styles from './CodeEditorPanel.module.css' /** Map a SiteFile to the editor's highlighting language (no CM6 imports here). */ @@ -75,7 +77,13 @@ const PANEL_WIDTH = 800 * bundle (~600 kB) sits behind a single `React.lazy` boundary further down, * so we only pay for it the first time the user opens a text file. */ -export function CodeEditorPanel() { +interface CodeEditorPanelProps { + runtimeValidation?: RuntimeScriptValidationState +} + +const EMPTY_DIAGNOSTICS: SiteRuntimeDiagnostic[] = [] + +export function CodeEditorPanel({ runtimeValidation }: CodeEditorPanelProps) { // ── Store subscriptions ────────────────────────────────────────────────── const activeEditorFileId = useEditorStore((s) => s.activeEditorFileId) const activeCodeBuffer = useEditorStore((s) => s.activeCodeBuffer) @@ -153,6 +161,13 @@ export function CodeEditorPanel() { const isTextFile = activeFile && !isAsset const isScriptFile = activeFile?.type === 'script' const isStyleFile = activeFile?.type === 'style' + const runtimeDiagnostics = runtimeValidation?.diagnostics ?? EMPTY_DIAGNOSTICS + const activeFileDiagnostics = activeFile + ? runtimeDiagnostics.filter((diagnostic) => ( + diagnostic.fileId === activeFile.id || + (!diagnostic.fileId && diagnostic.path === activeFile.path) + )) + : EMPTY_DIAGNOSTICS // Editor props for the active document — either a node-prop buffer or a file. const editorDoc = activeCodeBuffer @@ -183,7 +198,6 @@ export function CodeEditorPanel() { return (