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.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion server/handlers/cms/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<ReturnType<typeof publishDraftSite>>
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',
Expand Down
79 changes: 56 additions & 23 deletions server/handlers/cms/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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'
Expand Down Expand Up @@ -82,6 +86,29 @@ function runtimeRequestPackageJson(raw: unknown): SitePackageJson {
}
}

function runtimeRequestSite(raw: Record<string, unknown>): 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<Response | null> {
const url = new URL(req.url)

Expand Down Expand Up @@ -131,6 +158,33 @@ export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise<R
}
}

if (url.pathname === '/admin/api/cms/runtime/validate') {
const user = await requireCapability(req, db, 'site.read')
if (user instanceof Response) return user
if (req.method !== 'POST') return methodNotAllowed()

const RuntimeValidationBodySchema = Type.Object({
site: Type.Record(Type.String(), Type.Unknown()),
})
const body = await readValidatedBody(req, RuntimeValidationBodySchema)
if (!body) return badRequest('Invalid request body')

try {
const site = runtimeRequestSite(body.site)
const build = await buildSiteRuntimeScripts({
site,
target: 'publish',
assetBasePath: '/_instatic/runtime-validation/',
dependencyCache: await runtimeDependencyCache(site),
scriptSelection: 'all-enabled',
})
return jsonResponse({ diagnostics: build.diagnostics })
} catch (err) {
if (err instanceof SiteValidationError) return badRequest(err.message)
return badRequest(getErrorMessage(err, 'Runtime script validation failed'))
}
}

if (url.pathname === '/admin/api/cms/runtime/preview') {
// Preview is a render — the right gate is the read floor for the site
// editor, not page-metadata edit. A Designer holding `site.style.edit`
Expand Down Expand Up @@ -158,37 +212,16 @@ export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise<R
if (!pageId) return badRequest('Missing pageId')

try {
const shell: SiteShell = validateSite(body.site)
// The editor sends the full in-memory SiteDocument (shell + pages + VCs).
// Parse each component separately so validateVisualComponents can run.
const rawPages = Array.isArray(body.site.pages) ? body.site.pages : []
const rawVCs = Array.isArray(body.site.visualComponents) ? body.site.visualComponents : []
const parsedVCs = rawVCs.flatMap((raw) => {
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,
Expand Down
3 changes: 2 additions & 1 deletion server/publish/publishSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions server/publish/runtime/buildError.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading