ReviewOS

also looking at this

stacks/dtsx

feat(stx): generate component declarations

#3105
Merged chris wants to merge codex/stx-component-declarations into main
19 files +894 -37

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/dtsx/test/config.test.tsmodified+2-2
Changes to packages/dtsx/test/config.test.ts
@@ -20,8 +20,8 @@ async function createProject(name: string): Promise<string> {
2020}
2121
2222describe('getConfig', () => {
23 it('discovers every TypeScript module source format by default', () => {
24 expect(defaultConfig.entrypoints).toEqual(['**/*.{ts,tsx,mts,cts}'])
23 it('discovers supported module and component source formats by default', () => {
24 expect(defaultConfig.entrypoints).toEqual(['**/*.{ts,tsx,mts,cts,vue,stx}'])
2525 })
2626
2727 it('isolates cached configuration by project directory', async () => {
packages/dtsx/test/fixtures/stx/BladeAlert.stxadded+18-0
Changes to packages/dtsx/test/fixtures/stx/BladeAlert.stx
@@ -0,0 +1,18 @@
1{{-- This is the same typed-props convention used by STX's Blade examples. --}}
2@ts
3export interface AlertProps {
4 type: 'success' | 'info' | 'warning' | 'error'
5 message: string
6 dismissible?: boolean
7}
8
9const { type, message, dismissible = false } = props as AlertProps
10const getAlertClass = () => `alert-${type}`
11@endts
12
13<div class="alert ">
14 <p>{{ message }}</p>
15 @if(dismissible)
16 <button>Close</button>
17 @endif
18</div>
packages/dtsx/test/fixtures/stx/MixedBlocks.stxadded+16-0
Changes to packages/dtsx/test/fixtures/stx/MixedBlocks.stx
@@ -0,0 +1,16 @@
1@ts
2export type ServerStatus = 'ready' | 'busy'
3export const STATUS_ATTRIBUTE = 'data-status' as const
4@endts
5
6<script client lang="ts">
7interface Props {
8 status: ServerStatus
9 retries?: number
10}
11
12const props = defineProps<Props>()
13const retries = state(props.retries ?? 0)
14</script>
15
16<div data-status="">{{ retries }}</div>
packages/dtsx/test/fixtures/stx/OptionsBadge.stxadded+14-0
Changes to packages/dtsx/test/fixtures/stx/OptionsBadge.stx
@@ -0,0 +1,14 @@
1<script lang="ts">
2import type { BadgeMeta } from './types'
3
4export default {
5 props: {
6 label: { type: String, required: true },
7 count: Number,
8 active: Boolean,
9 meta: Object as PropType<BadgeMeta>,
10 },
11}
12</script>
13
14<span>{{ label }}</span>
packages/dtsx/test/fixtures/stx/TemplateOnly.stxadded+3-0
Changes to packages/dtsx/test/fixtures/stx/TemplateOnly.stx
@@ -0,0 +1,3 @@
1<article>
2 <slot></slot>
3</article>
packages/dtsx/test/fixtures/stx/TypedPanel.stxadded+36-0
Changes to packages/dtsx/test/fixtures/stx/TypedPanel.stx
@@ -0,0 +1,36 @@
1<script server lang="ts">
2import type { PanelTone } from './types'
3
4export const PANEL_VERSION = 2 as const
5
6interface PanelProps {
7 /** Visible heading. */
8 title: string
9 tone?: PanelTone
10 count?: number
11}
12
13interface PanelEmits {
14 (event: 'close'): void
15 (event: 'select', id: number): void
16}
17
18interface PanelSlots {
19 default: () => unknown
20 header?: (props: { title: string }) => unknown
21}
22
23const props = withDefaults(defineProps<PanelProps>(), {
24 tone: 'neutral',
25 count: 0,
26})
27const emit = defineEmits<PanelEmits>()
28const slots = defineSlots<PanelSlots>()
29const close = () => emit('close')
30defineExpose({ close })
31</script>
32
33<section data-tone="">
34 <slot name="header" :title="props.title"></slot>
35 <slot></slot>
36</section>
packages/dtsx/test/fixtures/stx/types.tsadded+6-0
Changes to packages/dtsx/test/fixtures/stx/types.ts
@@ -0,0 +1,6 @@
1export type PanelTone = 'neutral' | 'brand' | 'danger'
2
3export interface BadgeMeta {
4 id: string
5 tags: readonly string[]
6}
packages/dtsx/test/stx.test.tsadded+305-0
Changes to packages/dtsx/test/stx.test.ts
@@ -0,0 +1,305 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { generate } from '../src/generator'
6import { processSource } from '../src/process-source'
7import { isStxFile, parseStxScripts, transformStxToTs } from '../src/stx'
8
9const fixturesDir = join(import.meta.dir, 'fixtures', 'stx')
10const tempDirs: string[] = []
11
12async function createTempDir(): Promise<string> {
13 const tempDir = await mkdtemp(join(tmpdir(), 'dtsx-stx-'))
14 tempDirs.push(tempDir)
15 return tempDir
16}
17
18afterEach(async () => {
19 await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
20})
21
22const stxShim = `declare module '@stacksjs/stx' {
23 export type DefineComponent<Props = {}, RawBindings = {}, Data = unknown> = {
24 new (...args: unknown[]): { $props: Props }
25 props?: Props
26 setup?: (props: Props) => RawBindings | void
27 data?: Data
28 }
29}
30`
31
32async function expectParsesUnderTsc(dts: string, dependencies: Record<string, string> = {}): Promise<void> {
33 const tempDir = await createTempDir()
34 await writeFile(join(tempDir, 'out.d.ts'), dts)
35 await writeFile(join(tempDir, 'stx.d.ts'), stxShim)
36 await Promise.all(Object.entries(dependencies).map(([name, content]) => writeFile(join(tempDir, name), content)))
37 const tscBin = join(import.meta.dir, '..', '..', '..', 'node_modules', '.bin', 'tsc')
38 const proc = Bun.spawnSync([tscBin, '--noEmit', '--strict', 'out.d.ts', 'stx.d.ts', ...Object.keys(dependencies)], { cwd: tempDir })
39 const output = `${proc.stdout.toString()}${proc.stderr.toString()}`
40 expect(proc.exitCode === 0 ? '' : output).toBe('')
41}
42
43async function generateFixtures(entrypoints: string[], isolatedDeclarations: boolean): Promise<string> {
44 const tempDir = await createTempDir()
45 const srcDir = join(tempDir, 'src')
46 const outDir = join(tempDir, 'dist')
47 await cp(fixturesDir, srcDir, { recursive: true })
48 await generate({
49 cwd: tempDir,
50 root: './src',
51 outdir: './dist',
52 entrypoints,
53 isolatedDeclarations,
54 clean: true,
55 })
56 return outDir
57}
58
59describe('stx component parsing', () => {
60 it('detects only terminal .stx extensions', () => {
61 expect(isStxFile('/src/Panel.stx')).toBe(true)
62 expect(isStxFile('/src/Panel.ts')).toBe(false)
63 expect(isStxFile('/src/Panel.stx.ts')).toBe(false)
64 expect(isStxFile('/src/Panel.STX')).toBe(false)
65 })
66
67 it('collects server, client, universal and Blade TypeScript blocks in document order', () => {
68 const source = [
69 '@ts',
70 'export type First = string',
71 '@endts',
72 '<script server lang="ts">export const second = 2</script>',
73 '<script client type="text/typescript">export const third = 3</script>',
74 '<script>export const fourth = 4</script>',
75 ].join('\n')
76 const blocks = parseStxScripts(source)
77 expect(blocks.map(block => block.kind)).toEqual(['ts', 'script', 'script', 'script'])
78 expect(blocks.map(block => block.context)).toEqual(['universal', 'server', 'client', 'universal'])
79 expect(blocks.map(block => block.lang)).toEqual(['ts', 'ts', 'ts', 'js'])
80 expect(blocks.map(block => block.content.trim())).toEqual([
81 'export type First = string',
82 'export const second = 2',
83 'export const third = 3',
84 'export const fourth = 4',
85 ])
86 })
87
88 it('does not close a Blade block for @endts text inside its code', () => {
89 const source = `@ts\nconst marker = '@endts'\nexport interface Props { value: string }\nconst props = {} as Props\n@endts\n<div />`
90 const blocks = parseStxScripts(source)
91 expect(blocks).toHaveLength(1)
92 expect(blocks[0].content).toContain(`const marker = '@endts'`)
93 expect(blocks[0].content).toContain('interface Props')
94 })
95
96 it('keeps an unfinished Blade block usable while a file is being edited', () => {
97 const blocks = parseStxScripts('@ts\nexport interface Props { value: string }')
98 expect(blocks).toHaveLength(1)
99 expect(blocks[0].content).toContain('interface Props')
100 })
101
102 it('ignores directive-looking text in plain runtime script strings', () => {
103 const source = `<script>\nconst docs = \`export default { props: ['fake'] }\`\n</script>`
104 const dts = processSource(source, 'RuntimeDocs.stx')
105 expect(dts).toContain('DefineComponent<{}>')
106 expect(dts).not.toContain('fake?: unknown')
107 })
108})
109
110describe('stx component declaration transform', () => {
111 it('declares modern STX props, emits, slots, exposed values and named exports', async () => {
112 const source = await readFile(join(fixturesDir, 'TypedPanel.stx'), 'utf8')
113 const dts = processSource(source, 'TypedPanel.stx')
114 expect(dts).toContain(`import type { DefineComponent } from '@stacksjs/stx';`)
115 expect(dts).toContain(`import type { PanelTone } from './types';`)
116 expect(dts).toContain('title: string')
117 expect(dts).toContain('tone?: PanelTone')
118 expect(dts).toContain('count?: number')
119 expect(dts).toContain('readonly __stxEmits?')
120 expect(dts).toContain(`(event: 'select', id: number): void`)
121 expect(dts).toContain('readonly __stxSlots?: PanelSlots')
122 expect(dts).toContain('readonly __stxExposed?')
123 expect(dts).toContain('PANEL_VERSION')
124 expect(dts).not.toContain('defineProps')
125 expect(dts).not.toContain('withDefaults')
126 expect(dts).not.toContain('declare const props')
127 expect(dts).toContain('declare const close: () => unknown')
128 })
129
130 it('declares Blade-style props assertions without leaking render helpers', async () => {
131 const source = await readFile(join(fixturesDir, 'BladeAlert.stx'), 'utf8')
132 const dts = processSource(source, 'BladeAlert.stx')
133 expect(dts).toContain(`export declare interface AlertProps`)
134 expect(dts).toContain(`DefineComponent<AlertProps>`)
135 expect(dts).not.toContain('getAlertClass')
136 expect(dts).not.toContain('declare const type')
137 await expectParsesUnderTsc(dts)
138 })
139
140 it('maps STX options-style runtime props', async () => {
141 const source = await readFile(join(fixturesDir, 'OptionsBadge.stx'), 'utf8')
142 const dts = processSource(source, 'OptionsBadge.stx')
143 expect(dts).toContain('label: string')
144 expect(dts).toContain('count?: number')
145 expect(dts).toContain('active?: boolean')
146 expect(dts).toContain('meta?: BadgeMeta')
147 expect(dts).not.toContain('PropType')
148 })
149
150 it('merges Blade, client and server declarations in source order', async () => {
151 const source = await readFile(join(fixturesDir, 'MixedBlocks.stx'), 'utf8')
152 const dts = processSource(source, 'MixedBlocks.stx')
153 expect(dts).toContain(`export type ServerStatus = 'ready' | 'busy';`)
154 expect(dts).toContain(`STATUS_ATTRIBUTE`)
155 expect(dts).toContain('status: ServerStatus')
156 expect(dts).toContain('retries?: number')
157 expect(dts).not.toContain('declare const retries')
158 })
159
160 it('emits an empty STX component for template-only files', async () => {
161 const source = await readFile(join(fixturesDir, 'TemplateOnly.stx'), 'utf8')
162 const dts = processSource(source, 'TemplateOnly.stx')
163 expect(dts).toContain('DefineComponent<{}>')
164 expect(dts).toContain('export default __dtsx_component__;')
165 await expectParsesUnderTsc(dts)
166 })
167
168 it('supports inline object props and typed $props annotations', () => {
169 const inline = processSource(`@ts\nconst local = props as { id: string; nested?: { count: number } }\n@endts`, 'Inline.stx')
170 expect(inline).toContain('id: string')
171 expect(inline).toContain('nested?: { count: number }')
172
173 const annotation = processSource(`@ts\nimport type { ExternalProps } from './external'\ndeclare const $props: ExternalProps\n@endts`, 'Annotated.stx')
174 expect(annotation).toContain('DefineComponent<ExternalProps>')
175 expect(annotation).toContain(`from './external'`)
176 })
177
178 it('infers legacy Blade-style $props access with useful default-value types', async () => {
179 const source = `<script server>
180export const title = $props.title || 'Untitled'
181export const count = $props.count ?? 0
182export const enabled = $props.enabled !== false
183export const items = $props.items || []
184export const metadata = $props['metadata'] || {}
185export const opaque = $props.value
186</script>`
187 const dts = processSource(source, 'LegacyProps.stx')
188 expect(dts).toContain('title?: string')
189 expect(dts).toContain('count?: number')
190 expect(dts).toContain('enabled?: boolean')
191 expect(dts).toContain('items?: unknown[]')
192 expect(dts).toContain('metadata?: Record<string, unknown>')
193 expect(dts).toContain('value?: unknown')
194 await expectParsesUnderTsc(dts)
195 })
196
197 it('ignores $props examples in comments and strings while preserving Unicode offsets', () => {
198 const source = `<script server>
199const emoji = '🔥 $props.fake'
200// $props.commented
201export const label = $props.label || 'Label'
202</script>`
203 const dts = processSource(source, 'UnicodeProps.stx')
204 expect(dts).toContain('label?: string')
205 expect(dts).not.toContain('fake?:')
206 expect(dts).not.toContain('commented?:')
207 })
208
209 it('always declares components whose client scripts begin with runtime expressions', () => {
210 const iife = processSource(`<script client>\n(() => { const ready = true })()\n</script>`, 'Iife.stx')
211 const browserCall = processSource(`<script client>\nwindow.addEventListener('ready', () => {})\n</script>`, 'BrowserCall.stx')
212 expect(iife).toContain('export default __dtsx_component__;')
213 expect(browserCall).toContain('export default __dtsx_component__;')
214 })
215
216 it('handles comments and nested generic types without corrupting declarations', async () => {
217 const source = `<script server lang="ts">
218defineProps<{
219 /** Values grouped by key. */
220 groups: ReadonlyMap<string, Array<{ id: string; run: () => Promise<void> }>>
221 label?: string // accessible label
222}>()
223</script>`
224 const dts = processSource(source, 'Nested.stx')
225 expect(dts).toContain('ReadonlyMap<string, Array<{ id: string; run: () => Promise<void> }>>')
226 expect(dts).toContain('/* accessible label */')
227 expect(dts).not.toMatch(/\*\/;/)
228 await expectParsesUnderTsc(dts)
229 })
230
231 it('produces equivalent public contracts in semantic and isolated modes', async () => {
232 const source = await readFile(join(fixturesDir, 'TypedPanel.stx'), 'utf8')
233 const semantic = processSource(source, 'TypedPanel.stx', true, ['bun'], false)
234 const isolated = processSource(source, 'TypedPanel.stx', true, ['bun'], true)
235 for (const expected of ['title: string', 'tone?: PanelTone', '__stxEmits', '__stxSlots', 'PANEL_VERSION']) {
236 expect(semantic).toContain(expected)
237 expect(isolated).toContain(expected)
238 }
239 expect(isolated).toContain('export default __dtsx_component__;')
240 })
241
242 it('returns a plain default-export script unchanged', () => {
243 const virtual = transformStxToTs(`<script>\nconst component = class Card {}\nexport default component\n</script>`)
244 expect(virtual).toContain('export default component')
245 expect(virtual).not.toContain('__dtsx_component__')
246 })
247})
248
249describe('stx project generation', () => {
250 for (const isolatedDeclarations of [false, true]) {
251 it(`emits and typechecks STX entrypoints in ${isolatedDeclarations ? 'isolated' : 'semantic'} mode`, async () => {
252 const outDir = await generateFixtures(['**/*.{stx,ts}'], isolatedDeclarations)
253 const panel = await readFile(join(outDir, 'TypedPanel.d.ts'), 'utf8')
254 const blade = await readFile(join(outDir, 'BladeAlert.d.ts'), 'utf8')
255 const options = await readFile(join(outDir, 'OptionsBadge.d.ts'), 'utf8')
256 const mixed = await readFile(join(outDir, 'MixedBlocks.d.ts'), 'utf8')
257 const template = await readFile(join(outDir, 'TemplateOnly.d.ts'), 'utf8')
258 const types = await readFile(join(outDir, 'types.d.ts'), 'utf8')
259
260 expect(panel).toContain('title: string')
261 expect(blade).toContain('DefineComponent<AlertProps>')
262 expect(options).toContain('meta?: BadgeMeta')
263 expect(mixed).toContain('status: ServerStatus')
264 expect(template).toContain('DefineComponent<{}>')
265 expect(types).toContain('PanelTone')
266
267 await expectParsesUnderTsc(panel, {
268 'types.d.ts': types,
269 })
270 })
271 }
272
273 it('discovers .stx files through default entrypoints', async () => {
274 const tempDir = await createTempDir()
275 await cp(fixturesDir, join(tempDir, 'src'), { recursive: true })
276 await generate({ cwd: tempDir, root: './src', outdir: './dist', clean: true })
277 expect(await readFile(join(tempDir, 'dist', 'TypedPanel.d.ts'), 'utf8')).toContain('title: string')
278 })
279
280 it('auto-includes and rewrites explicitly imported .stx components', async () => {
281 const tempDir = await createTempDir()
282 const srcDir = join(tempDir, 'src')
283 const outDir = join(tempDir, 'dist')
284 await cp(fixturesDir, srcDir, { recursive: true })
285 await writeFile(join(srcDir, 'index.ts'), `import TypedPanel from './TypedPanel.stx'\nexport { TypedPanel }\n`)
286 await generate({ cwd: tempDir, root: './src', outdir: './dist', entrypoints: ['index.ts'], clean: true })
287
288 const index = await readFile(join(outDir, 'index.d.ts'), 'utf8')
289 expect(index).toContain(`from './TypedPanel'`)
290 expect(index).not.toContain('.stx')
291 expect(await readFile(join(outDir, 'TypedPanel.d.ts'), 'utf8')).toContain('title: string')
292 })
293
294 it('resolves extensionless imports to STX components', async () => {
295 const tempDir = await createTempDir()
296 const srcDir = join(tempDir, 'src')
297 const outDir = join(tempDir, 'dist')
298 await cp(fixturesDir, srcDir, { recursive: true })
299 await writeFile(join(srcDir, 'index.ts'), `export { default as TemplateOnly } from './TemplateOnly'\n`)
300 await generate({ cwd: tempDir, root: './src', outdir: './dist', entrypoints: ['index.ts'], clean: true })
301
302 expect(await readFile(join(outDir, 'index.d.ts'), 'utf8')).toContain(`from './TemplateOnly'`)
303 expect(await readFile(join(outDir, 'TemplateOnly.d.ts'), 'utf8')).toContain('DefineComponent<{}>')
304 })
305})