Skip to content

Commit 2831aad

Browse files
devallibusclaude
andauthored
fix: close TSL post-merge follow-ups (#62)
* fix: TSL shader detail page crash and language validation ShaderDetail is now a discriminated union (GlslShaderDetail | TslShaderDetail), matching the PlaygroundSession pattern. The web detail page, loadShaderDetail, and registry-backed source loader all branch on language to read the correct source files and render the appropriate UI. Playground session creation now rejects invalid language strings (anything other than "glsl" or "tsl") with a descriptive error. MCP tool schemas constrain the language parameter to an enum. Fixes: - /shaders/tsl-gradient-wave no longer crashes (was TypeError on manifest.files.vertex) - createSession({ language: "foo" }) now throws instead of silently creating a malformed session 6 new tests cover TSL detail loading and invalid language rejection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: cover registry-backed shader detail branches --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent da6a038 commit 2831aad

9 files changed

Lines changed: 303 additions & 35 deletions

File tree

apps/web/src/lib/server/load-shader-detail.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export type ShaderDetailRecipe = {
2525
requirements: string[]
2626
}
2727

28-
export type ShaderDetail = {
28+
type ShaderDetailBase = {
2929
name: string
3030
displayName: string
3131
version: string
@@ -46,8 +46,6 @@ export type ShaderDetail = {
4646
uniforms: ShaderDetailUniform[]
4747
inputs: Array<{ name: string; kind: string; description: string; required: boolean }>
4848
outputs: Array<{ name: string; kind: string; description: string }>
49-
vertexSource: string
50-
fragmentSource: string
5149
recipes: ShaderDetailRecipe[]
5250
previewSvg: string | null
5351
provenance: {
@@ -69,6 +67,19 @@ export type ShaderDetail = {
6967
}
7068
}
7169

70+
export type GlslShaderDetail = ShaderDetailBase & {
71+
language: 'glsl'
72+
vertexSource: string
73+
fragmentSource: string
74+
}
75+
76+
export type TslShaderDetail = ShaderDetailBase & {
77+
language: 'tsl'
78+
tslSource: string
79+
}
80+
81+
export type ShaderDetail = GlslShaderDetail | TslShaderDetail
82+
7283
/**
7384
* Load a single shader's full detail from its directory on disk.
7485
* This is the pure filesystem logic extracted from the getShaderDetail server function.
@@ -77,19 +88,14 @@ export async function loadShaderDetail(shaderDir: string): Promise<ShaderDetail>
7788
const manifestRaw = await readFile(join(shaderDir, 'shader.json'), 'utf8')
7889
const manifest = JSON.parse(manifestRaw) as Record<string, unknown>
7990

80-
const files = manifest.files as { vertex: string; fragment: string }
91+
const language = (manifest.language as string) ?? 'glsl'
8192
const capabilityProfile = manifest.capabilityProfile as Record<string, unknown>
8293
const compatibility = manifest.compatibility as Record<string, unknown>
8394
const provenance = manifest.provenance as Record<string, unknown>
8495
const attribution = provenance.attribution as Record<string, unknown>
8596
const preview = manifest.preview as { path: string; format: string }
8697
const recipeMeta = manifest.recipes as Array<Record<string, unknown>>
8798

88-
const [vertexSource, fragmentSource] = await Promise.all([
89-
readFile(join(shaderDir, files.vertex), 'utf8'),
90-
readFile(join(shaderDir, files.fragment), 'utf8'),
91-
])
92-
9399
const recipes: ShaderDetailRecipe[] = await Promise.all(
94100
recipeMeta.map(async (r) => {
95101
const code = await readFile(join(shaderDir, r.path as string), 'utf8')
@@ -109,13 +115,13 @@ export async function loadShaderDetail(shaderDir: string): Promise<ShaderDetail>
109115
previewSvg = await readFile(join(shaderDir, preview.path), 'utf8')
110116
}
111117

112-
return {
118+
const base: Omit<ShaderDetail, 'language' | 'vertexSource' | 'fragmentSource' | 'tslSource'> = {
113119
name: manifest.name as string,
114120
displayName: manifest.displayName as string,
115121
version: manifest.version as string,
116122
summary: manifest.summary as string,
117123
description: manifest.description as string,
118-
author: manifest.author as ShaderDetail['author'],
124+
author: manifest.author as ShaderDetailBase['author'],
119125
license: manifest.license as string,
120126
tags: manifest.tags as string[],
121127
category: manifest.category as string,
@@ -128,20 +134,31 @@ export async function loadShaderDetail(shaderDir: string): Promise<ShaderDetail>
128134
material: compatibility.material as string,
129135
environments: compatibility.environments as string[],
130136
uniforms: manifest.uniforms as ShaderDetailUniform[],
131-
inputs: manifest.inputs as ShaderDetail['inputs'],
132-
outputs: manifest.outputs as ShaderDetail['outputs'],
133-
vertexSource,
134-
fragmentSource,
137+
inputs: manifest.inputs as ShaderDetailBase['inputs'],
138+
outputs: manifest.outputs as ShaderDetailBase['outputs'],
135139
recipes,
136140
previewSvg,
137141
provenance: {
138142
sourceKind: provenance.sourceKind as string,
139-
sources: (provenance.sources as ShaderDetail['provenance']['sources']) ?? [],
143+
sources: (provenance.sources as ShaderDetailBase['provenance']['sources']) ?? [],
140144
attribution: {
141145
summary: attribution.summary as string,
142146
requiredNotice: attribution.requiredNotice as string | undefined,
143147
},
144148
notes: provenance.notes as string | undefined,
145149
},
146150
}
151+
152+
if (language === 'tsl') {
153+
const tslEntry = manifest.tslEntry as string
154+
const tslSource = await readFile(join(shaderDir, tslEntry), 'utf8')
155+
return { ...base, language: 'tsl', tslSource }
156+
}
157+
158+
const files = manifest.files as { vertex: string; fragment: string }
159+
const [vertexSource, fragmentSource] = await Promise.all([
160+
readFile(join(shaderDir, files.vertex), 'utf8'),
161+
readFile(join(shaderDir, files.fragment), 'utf8'),
162+
])
163+
return { ...base, language: 'glsl', vertexSource, fragmentSource }
147164
}

apps/web/src/lib/server/playground-db.test.node.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,18 @@ runTest('updateMetadata stores metadata', () => {
188188
assert.deepEqual(session.metadata, metadata)
189189
})
190190

191+
runTest('createSession rejects invalid language', () => {
192+
assert.throws(
193+
() => createSession({ language: 'foo' as 'glsl' }),
194+
/Invalid language "foo"/,
195+
)
196+
})
197+
198+
runTest('createSession rejects arbitrary language strings', () => {
199+
assert.throws(
200+
() => createSession({ language: 'wgsl' as 'glsl' }),
201+
/Invalid language "wgsl"/,
202+
)
203+
})
204+
191205
console.log('playground-db tests passed')

apps/web/src/lib/server/playground-db.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,15 @@ const DEFAULT_UNIFORMS: UniformDefinition[] = [
3434
{ name: 'uTime', type: 'float', defaultValue: 0, description: 'Elapsed time in seconds' },
3535
]
3636

37+
const VALID_LANGUAGES = new Set(['glsl', 'tsl'])
38+
3739
function validateCreateSessionRequest(opts?: CreateSessionRequest) {
3840
const language = opts?.language ?? 'glsl'
3941

42+
if (!VALID_LANGUAGES.has(language)) {
43+
throw new Error(`Invalid language "${language}". Must be "glsl" or "tsl".`)
44+
}
45+
4046
if (language === 'glsl') {
4147
if ('tslSource' in (opts ?? {}) && opts?.tslSource !== undefined) {
4248
throw new Error('GLSL sessions do not accept tslSource')

apps/web/src/lib/server/shader-detail.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { getShaderDetailFromSource } from './shader-source.ts'
33

44
export type {
55
ShaderDetail,
6+
GlslShaderDetail,
7+
TslShaderDetail,
68
ShaderDetailUniform,
79
ShaderDetailRecipe,
810
} from './load-shader-detail.ts'

apps/web/src/lib/server/shader-source.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ShaderEntry } from './list-shaders.ts'
2-
import type { ShaderDetail, ShaderDetailRecipe } from './load-shader-detail.ts'
2+
import type { ShaderDetail, ShaderDetailRecipe, GlslShaderDetail, TslShaderDetail } from './load-shader-detail.ts'
33

44
/**
55
* Environment-aware shader data source.
@@ -62,13 +62,15 @@ export async function getShaderDetailFromSource(name: string): Promise<ShaderDet
6262
}),
6363
)
6464

65-
return {
65+
const language = (bundle.language as string) ?? 'glsl'
66+
67+
const base = {
6668
name: bundle.name as string,
6769
displayName: bundle.displayName as string,
6870
version: bundle.version as string,
6971
summary: bundle.summary as string,
7072
description: bundle.description as string,
71-
author: bundle.author as ShaderDetail['author'],
73+
author: bundle.author as GlslShaderDetail['author'],
7274
license: bundle.license as string,
7375
tags: bundle.tags as string[],
7476
category: bundle.category as string,
@@ -81,23 +83,31 @@ export async function getShaderDetailFromSource(name: string): Promise<ShaderDet
8183
material: compatibility.material as string,
8284
environments: compatibility.environments as string[],
8385
uniforms: uniformsFull,
84-
inputs: bundle.inputs as ShaderDetail['inputs'],
85-
outputs: bundle.outputs as ShaderDetail['outputs'],
86-
vertexSource: bundle.vertexSource as string,
87-
fragmentSource: bundle.fragmentSource as string,
86+
inputs: bundle.inputs as GlslShaderDetail['inputs'],
87+
outputs: bundle.outputs as GlslShaderDetail['outputs'],
8888
recipes,
8989
// previewSvg is not available in the registry bundle
9090
previewSvg: null,
9191
provenance: {
9292
sourceKind: provenance.sourceKind as string,
93-
sources: (provenance.sources as ShaderDetail['provenance']['sources']) ?? [],
93+
sources: (provenance.sources as GlslShaderDetail['provenance']['sources']) ?? [],
9494
attribution: {
9595
summary: attribution.summary as string,
9696
requiredNotice: attribution.requiredNotice as string | undefined,
9797
},
9898
notes: provenance.notes as string | undefined,
9999
},
100100
}
101+
102+
if (language === 'tsl') {
103+
return { ...base, language: 'tsl' as const, tslSource: bundle.tslSource as string }
104+
}
105+
return {
106+
...base,
107+
language: 'glsl' as const,
108+
vertexSource: bundle.vertexSource as string,
109+
fragmentSource: bundle.fragmentSource as string,
110+
}
101111
}
102112

103113
// Fallback: filesystem (local dev)

0 commit comments

Comments
 (0)