uppy/packages/@uppy/components/migrate.mjs
Merlijn Vos fa23832f6a
@uppy/vue: support kebab-case props in generated components (#6125)
## Summary

- Fix Vue components to work with kebab-case props (`:edit-file` instead
of `:editFile`)
- Update `migrate.mjs` to parse prop names from TypeScript source files
and generate explicit `props` arrays

## Problem

The generated Vue components didn't work correctly with Vue's standard
kebab-case prop convention:

```vue
<!-- This didn't work -->
<FilesList :edit-file="handleEdit" />

<!-- Only this worked (non-standard) -->
<FilesList :editFile="handleEdit" />
```

## Root Cause

The original Vue template used `attrs` to pass props to Preact:

```ts
setup(props, { attrs }) {
  preactRender(preactH(PreactComponent, {
    ...(attrs as Props),  // attrs preserves kebab-case!
    ctx,
  }), container)
}
```

When using `:edit-file` in a Vue template, Vue passes
`attrs['edit-file']` (kebab-case preserved), but Preact expects
`editFile` (camelCase).

## Solution

Generate Vue components with explicit `props` declarations:

```ts
defineComponent({
  props: ['editFile', 'columns', 'imageThumbnail'],
  setup(props) {
    preactRender(preactH(PreactComponent, {
      ...props,  // Vue already converted kebab → camelCase
      ctx,
    }), container)
  }
})
```

When Vue components declare their props, Vue automatically converts
kebab-case template usage to camelCase in the `props` object. This is
standard Vue behavior.

## Why Simpler Alternatives Don't Work

### "Just use `props` instead of `attrs`"

Without a `props` declaration, Vue doesn't know which attributes are
props. The `props` object will be empty and everything goes to `attrs`:

```ts
// Without props declaration
defineComponent({
  setup(props, { attrs }) {
    // props = {}  (empty!)
    // attrs = { 'edit-file': fn }  (kebab-case preserved)
  }
})
```

### "Accept all props dynamically"

Vue doesn't have a "accept all props and convert casing" option. You
must explicitly declare which props you expect for Vue to handle the
conversion.

### "Just document to use camelCase"

This works but violates Vue conventions. Every Vue developer expects
`:edit-file` to work.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Prakash <qxprakash@gmail.com>
2026-01-19 09:59:42 +01:00

295 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
// When new components are added (not when existing ones are changed),
// run this script to generate React, Vue, and Svelte wrappers
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Get the directory of this script
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(scriptDir, '../../..')
// Define paths
// source:
const COMPONENTS_DIR = path.join(rootDir, 'packages/@uppy/components/src')
// destinations:
const REACT_DIR = path.join(
rootDir,
'packages/@uppy/react/src/headless/generated',
)
const VUE_DIR = path.join(rootDir, 'packages/@uppy/vue/src/headless/generated')
const SVELTE_DIR = path.join(
rootDir,
'packages/@uppy/svelte/src/lib/components/headless/generated',
)
/**
* Parse prop names from a TypeScript component file
* Extracts property names from the Props type definition, excluding 'ctx'
*/
async function parsePropsFromFile(filePath) {
const content = await fs.readFile(filePath, 'utf-8')
// Match the Props type definition: export type ComponentNameProps = { ... }
const propsMatch = content.match(
/export\s+type\s+\w+Props\s*=\s*\{([^}]+)\}/s,
)
if (!propsMatch) {
return []
}
const propsBlock = propsMatch[1]
// Extract property names (handle optional ? and required properties)
const propNames = []
const propRegex = /^\s*(\w+)\??:/gm
for (const match of propsBlock.matchAll(propRegex)) {
const propName = match[1]
// Exclude 'ctx' as it's provided by the context
if (propName !== 'ctx') {
propNames.push(propName)
}
}
return propNames
}
// Templates
const REACT_TEMPLATE = `\
// This file was generated by build-components.mjs
// ANY EDITS WILL BE OVERWRITTEN!
import {
%%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { useContext, useEffect, useRef } from 'react'
import { UppyContext } from '../UppyContextProvider.js'
export default function %%ComponentName%%(props: Omit<%%PropsTypeName%%, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(%%PreactComponentName%%, {
...props,
ctx,
} satisfies %%PropsTypeName%%),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}
`
const VUE_TEMPLATE = `\
// This file was generated by build-components.mjs
// ANY EDITS WILL BE OVERWRITTEN!
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import {
%%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent({
name: '%%ComponentName%%',
props: %%PropsArray%%,
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function render%%ComponentName%%() {
if (containerRef.value) {
preactRender(
preactH(%%PreactComponentName%%, {
...props,
ctx,
} satisfies %%PropsTypeName%%),
containerRef.value,
)
}
}
onMounted(() => {
render%%ComponentName%%()
})
watch(ctx, () => {
render%%ComponentName%%()
})
watch(
() => props,
() => {
render%%ComponentName%%()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })
},
})
`
const SVELTE_TEMPLATE = `\
<script lang="ts">
// This file was generated by build-components.mjs
// ANY EDITS WILL BE OVERWRITTEN!
import { getContext, mount } from 'svelte'
import {
%%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%,
type UppyContext,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { UppyContextKey } from '../UppyContextProvider.svelte'
const props: Omit<%%PropsTypeName%%, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(%%PreactComponentName%%, {
...props,
ctx,
} satisfies %%PropsTypeName%%),
container,
)
}
})
</script>
<div bind:this={container}></div>
`
try {
// Check if components directory exists
await fs.access(COMPONENTS_DIR).catch(() => {
throw new Error(`Components directory not found: ${COMPONENTS_DIR}`)
})
await Promise.all(
[REACT_DIR, VUE_DIR, SVELTE_DIR].map(async (dir) => {
if (!existsSync(dir)) {
await fs.mkdir(dir, { recursive: true })
}
}),
)
// Read all files in components directory
const files = await fs.readdir(COMPONENTS_DIR)
// Filter for .tsx files
const tsxFiles = files.filter((file) => file.endsWith('.tsx'))
console.log(`Found ${tsxFiles.length} Preact component(s) to process\n`)
// Track generated components for index files
const reactComponents = []
const vueComponents = []
const svelteComponents = []
// Process each tsx file
for (const file of tsxFiles) {
try {
const componentName = path.basename(file, '.tsx')
const propsTypeName = `${componentName}Props`
const preactComponentName = `Preact${componentName}`
const filePath = path.join(COMPONENTS_DIR, file)
// Parse props from the source file
const propNames = await parsePropsFromFile(filePath)
const propsArray = JSON.stringify(propNames)
// Generate React wrapper
const reactContent = REACT_TEMPLATE.replace(
/%%ComponentName%%/g,
componentName,
)
.replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName)
// Generate Vue wrapper
const vueContent = VUE_TEMPLATE.replace(
/%%ComponentName%%/g,
componentName,
)
.replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName)
.replace(/%%PropsArray%%/g, propsArray)
// Generate Svelte wrapper
const svelteContent = SVELTE_TEMPLATE.replace(
/%%ComponentName%%/g,
componentName,
)
.replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName)
// Write files
const reactFilePath = path.join(REACT_DIR, `${componentName}.tsx`)
const vueFilePath = path.join(VUE_DIR, `${componentName}.ts`)
const svelteFilePath = path.join(SVELTE_DIR, `${componentName}.svelte`)
await fs.writeFile(reactFilePath, reactContent)
await fs.writeFile(vueFilePath, vueContent)
await fs.writeFile(svelteFilePath, svelteContent)
// Add to component lists for index files
reactComponents.push(componentName)
vueComponents.push(componentName)
svelteComponents.push(componentName)
console.log(
`${componentName} (props: ${propNames.join(', ') || 'none'})`,
)
} catch (error) {
console.error(`Error processing component ${file}:`, error)
}
}
// Generate index files
if (reactComponents.length > 0) {
const reactIndexContent = reactComponents
.map((name) => `export { default as ${name} } from './${name}.js'`)
.join('\n')
await fs.writeFile(
path.join(REACT_DIR, 'index.ts'),
`${reactIndexContent}\n`,
)
console.log(`\nExporting React components from ${REACT_DIR}`)
}
if (vueComponents.length > 0) {
const vueIndexContent = vueComponents
.map((name) => `export { default as ${name} } from './${name}.js'`)
.join('\n')
await fs.writeFile(path.join(VUE_DIR, 'index.ts'), `${vueIndexContent}\n`)
console.log(`Exporting Vue components from ${VUE_DIR}`)
}
if (svelteComponents.length > 0) {
const svelteIndexContent = svelteComponents
.map((name) => `export { default as ${name} } from './${name}.svelte'`)
.join('\n')
await fs.writeFile(
path.join(SVELTE_DIR, 'index.ts'),
`${svelteIndexContent}\n`,
)
console.log(`Exporting Svelte components from ${SVELTE_DIR}`)
}
console.log('\nAll wrappers and index files generated successfully!')
} catch (error) {
console.error('Error generating wrappers:', error)
process.exit(1)
}