| @@ -0,0 +1,305 @@ |
| 1 | +import { afterEach, describe, expect, it } from 'bun:test' |
| 2 | +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' |
| 3 | +import { tmpdir } from 'node:os' |
| 4 | +import { join } from 'node:path' |
| 5 | +import { generate } from '../src/generator' |
| 6 | +import { processSource } from '../src/process-source' |
| 7 | +import { isStxFile, parseStxScripts, transformStxToTs } from '../src/stx' |
| 8 | + |
| 9 | +const fixturesDir = join(import.meta.dir, 'fixtures', 'stx') |
| 10 | +const tempDirs: string[] = [] |
| 11 | + |
| 12 | +async function createTempDir(): Promise<string> { |
| 13 | + const tempDir = await mkdtemp(join(tmpdir(), 'dtsx-stx-')) |
| 14 | + tempDirs.push(tempDir) |
| 15 | + return tempDir |
| 16 | +} |
| 17 | + |
| 18 | +afterEach(async () => { |
| 19 | + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) |
| 20 | +}) |
| 21 | + |
| 22 | +const 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 | + |
| 32 | +async 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 | + |
| 43 | +async 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 | + |
| 59 | +describe('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 | + |
| 110 | +describe('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> |
| 180 | +export const title = $props.title || 'Untitled' |
| 181 | +export const count = $props.count ?? 0 |
| 182 | +export const enabled = $props.enabled !== false |
| 183 | +export const items = $props.items || [] |
| 184 | +export const metadata = $props['metadata'] || {} |
| 185 | +export 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> |
| 199 | +const emoji = '🔥 $props.fake' |
| 200 | + |
| 201 | +export 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"> |
| 218 | +defineProps<{ |
| 219 | + |
| 220 | + groups: ReadonlyMap<string, Array<{ id: string; run: () => Promise<void> }>> |
| 221 | + label?: string |
| 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 | + |
| 249 | +describe('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 | +}) |