diff --git a/README.md b/README.md index 4bad989..93d2ac2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

create-paraspell ✨

+

paraspell-cli ✨

ParaSpell logo @@ -23,7 +23,7 @@ ## Get started ```bash -pnpm create-paraspell +pnpm dlx paraspell-cli@latest ``` The wizard helps you choose what to build, shows you the final setup, creates @@ -37,9 +37,9 @@ pnpm dev Use npm, Yarn, or Bun instead if that is what your project uses: ```bash -npm create paraspell@latest -yarn create paraspell -bun create paraspell +npx paraspell-cli@latest +yarn dlx paraspell-cli@latest +bunx paraspell-cli@latest ``` Requires Node.js 24 or newer. @@ -85,19 +85,19 @@ Prefer explicit commands in CI or when you already know the setup you want: ```bash # React app using the XCM SDK and PAPI -npx create-paraspell@latest sdk react \ +npx paraspell-cli@latest sdk react \ --name my-xcm-app \ --client papi \ --package-manager pnpm # Vue app using the XCM API with EVM origins and swaps -npx create-paraspell@latest api vue \ +npx paraspell-cli@latest api vue \ --name my-xcm-api \ --extensions evm,swap \ --package-manager npm # Headless SDK server with swaps -npx create-paraspell@latest sdk node \ +npx paraspell-cli@latest sdk node \ --name my-xcm-server \ --client dedot \ --extensions swap @@ -107,9 +107,9 @@ Non-interactive environments use sensible defaults and leave dependency installation as an explicit CI step. ```bash -create-paraspell --help -create-paraspell sdk --help -create-paraspell api --help +paraspell-cli --help +paraspell-cli sdk --help +paraspell-cli api --help ``` ## A note about wallet secrets diff --git a/e2e/generated-projects.test.ts b/e2e/generated-projects.test.ts index 93fb215..f75ebe6 100644 --- a/e2e/generated-projects.test.ts +++ b/e2e/generated-projects.test.ts @@ -33,6 +33,7 @@ const PACKAGE_MANAGER_COMMANDS: Record< const GENERATION_CASES = BUILD_CASES.map( (testCase) => [caseId(testCase), testCase] as const, ); +const MATRIX_BUILD_TIMEOUT_MS = 10 * 60 * 1000; let root: string; let projectsRoot: string; @@ -59,35 +60,40 @@ describe('generated projects', () => { await generateProject(testCase, projectPath(testCase)); }); - it('builds every supported project combination', async () => { - const firstWorkspace = projectPath(BUILD_CASES[0]); - const allowBuilds = fs.readFileSync( - path.join(firstWorkspace, 'pnpm-workspace.yaml'), - 'utf8', - ); + it( + 'builds and lints every supported project combination', + async () => { + const firstWorkspace = projectPath(BUILD_CASES[0]); + const allowBuilds = fs.readFileSync( + path.join(firstWorkspace, 'pnpm-workspace.yaml'), + 'utf8', + ); - fs.writeFileSync( - path.join(root, 'package.json'), - JSON.stringify({ name: 'generated-projects', private: true }), - ); - fs.writeFileSync( - path.join(root, 'pnpm-workspace.yaml'), - `packages: + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name: 'generated-projects', private: true }), + ); + fs.writeFileSync( + path.join(root, 'pnpm-workspace.yaml'), + `packages: - projects/*-pnpm overrides: "@polkadot-api/json-rpc-provider": ^0.2.0 ${allowBuilds}`, - ); + ); - await runCommand('pnpm', ['install'], { - cwd: root, - }); - await runCommand( - 'pnpm', - ['--recursive', '--workspace-concurrency=4', 'run', 'build'], - { cwd: root }, - ); - }); + await runCommand('pnpm', ['install'], { + cwd: root, + }); + await runCommand('pnpm', ['-r', '--no-bail', 'run', 'build'], { + cwd: root, + }); + await runCommand('pnpm', ['-r', '--no-bail', 'run', 'lint'], { + cwd: root, + }); + }, + MATRIX_BUILD_TIMEOUT_MS, + ); const installAndBuild = async ( testCase: TGenerationCase, diff --git a/eslint.config.mjs b/eslint.config.mjs index 59a8069..e8baac0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import eslintConfigPrettier from 'eslint-config-prettier/flat'; import tseslint from 'typescript-eslint'; export default defineConfig({ - name: 'create-paraspell/typescript', + name: 'paraspell-cli/typescript', files: ['src/**/*.ts', 'e2e/**/*.ts'], extends: [ js.configs.recommended, diff --git a/package.json b/package.json index 8cba2e7..b675e5d 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "create-paraspell", + "name": "paraspell-cli", "version": "1.0.0", "description": "Scaffold ParaSpell XCM SDK and XCM API starter apps", "type": "module", "license": "MIT", "author": "ParaSpell", "bin": { - "create-paraspell": "dist/index.js" + "paraspell-cli": "dist/index.js" }, "files": [ "dist", diff --git a/src/generator/context.test.ts b/src/generator/context.test.ts index ee4dcc5..8ed9fa7 100644 --- a/src/generator/context.test.ts +++ b/src/generator/context.test.ts @@ -47,11 +47,7 @@ describe('createTemplateContext', () => { }, ); - it.each([ - [{ evm: false, swap: false, snowbridge: false }, 'Astar'], - [{ evm: true, swap: false, snowbridge: false }, 'Moonbeam'], - [{ evm: true, swap: false, snowbridge: true }, 'Ethereum'], - ])('selects the default origin for %o', (extensions, expected) => { + it('selects the Node start command', () => { const context = createTemplateContext({ kind: 'sdk', opts: { @@ -60,11 +56,10 @@ describe('createTemplateContext', () => { client: 'papi', packageManager: 'pnpm', out: '/tmp/example', - extensions, + extensions: { evm: false, swap: false, snowbridge: false }, }, }); - expect(context.defaultOriginChain).toBe(expected); expect(context.startCmd).toBe('pnpm start'); }); }); diff --git a/src/generator/context.ts b/src/generator/context.ts index f5e6b92..162c476 100644 --- a/src/generator/context.ts +++ b/src/generator/context.ts @@ -31,10 +31,5 @@ export const createTemplateContext = ({ projectKind: kind, extensions: opts.extensions, evmWallet: requiresEvmWallet(opts.extensions), - defaultOriginChain: opts.extensions.snowbridge - ? 'Ethereum' - : opts.extensions.evm - ? 'Moonbeam' - : 'Astar', }; }; diff --git a/src/generator/template-dependencies/package.json b/src/generator/template-dependencies/package.json index 96a8b7c..41fcd1d 100644 --- a/src/generator/template-dependencies/package.json +++ b/src/generator/template-dependencies/package.json @@ -2,7 +2,7 @@ "name": "@paraspell/cli-template-dependencies", "version": "0.0.0", "private": true, - "description": "Canonical dependency versions emitted by create-paraspell", + "description": "Canonical dependency versions emitted by paraspell-cli", "dependencies": { "@eslint/js": "^10.0.1", "@paraspell/descriptors": "14.1.0", @@ -23,6 +23,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-vue": "^6.0.8", + "@vue/tsconfig": "^0.9.1", "axios": "^1.18.1", "dedot": "^1.3.0", "dotenv": "^17.4.2", @@ -41,7 +42,7 @@ "tsx": "^4.23.1", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", - "viem": "2.55.5", + "viem": "2.55.11", "vite": "^8.1.5", "vite-plugin-wasm": "^3.6.0", "vue": "^3.5.40", diff --git a/src/generator/templates/index.test.ts b/src/generator/templates/index.test.ts index 40919ac..c00ba9e 100644 --- a/src/generator/templates/index.test.ts +++ b/src/generator/templates/index.test.ts @@ -1,45 +1,22 @@ import { describe, expect, it } from 'vitest'; import { + FRAMEWORKS, + PROJECT_TYPES, + SDK_CLIENTS, type TExtensions, type TFramework, - type TPackageManager, type TProjectType, type TSdkClient, } from '../../shared/project-options.js'; import { createTemplateContext } from '../context.js'; import { createTemplateFiles } from './index.js'; -type TTemplateCase = [ - kind: TProjectType, - framework: TFramework, - client: TSdkClient, - packageManager: TPackageManager, - extensions?: TExtensions[], -]; - -const templateCases: TTemplateCase[] = [ - ['sdk', 'react', 'papi', 'pnpm'], - ['sdk', 'vue', 'pjs', 'npm'], - [ - 'sdk', - 'vue', - 'papi', - 'pnpm', - [{ evm: false, swap: false, snowbridge: false }], - ], - ['sdk', 'node', 'dedot', 'yarn'], - ['api', 'react', 'pjs', 'yarn'], - ['api', 'vue', 'dedot', 'pnpm'], - ['api', 'node', 'papi', 'npm'], -]; - -const extensionSets: TExtensions[] = [ - { evm: false, swap: false, snowbridge: false }, - { evm: true, swap: false, snowbridge: false }, - { evm: false, swap: true, snowbridge: false }, - { evm: false, swap: false, snowbridge: true }, - { evm: true, swap: true, snowbridge: true }, -]; +const EXTENSION_COMBINATIONS: readonly TExtensions[] = [false, true].flatMap( + (evm) => + [false, true].flatMap((swap) => + [false, true].map((snowbridge) => ({ evm, swap, snowbridge })), + ), +); const renderTemplates = ( kind: TProjectType, @@ -64,33 +41,49 @@ const renderTemplates = ( }; describe('createTemplateFiles', () => { - it.each(templateCases)( - 'renders the %s %s templates', - (kind, framework, client, packageManager, caseExtensions) => { - for (const extensions of caseExtensions ?? extensionSets) { - const context = createTemplateContext({ - kind, - opts: { - framework, - name: `${kind}-${framework}`, - client, - packageManager, - out: '/tmp/not-written', - extensions, - }, - }); - const files = createTemplateFiles(context); - const paths = files.map((file) => file.path); + it('renders every supported project combination without invalid files', () => { + let combinations = 0; + + for (const kind of PROJECT_TYPES) { + for (const framework of FRAMEWORKS) { + const clients = kind === 'sdk' ? SDK_CLIENTS : (['papi'] as const); + + for (const client of clients) { + for (const extensions of EXTENSION_COMBINATIONS) { + const caseName = [ + kind, + framework, + client, + ...Object.entries(extensions) + .filter(([, enabled]) => enabled) + .map(([extension]) => extension), + ].join('-'); + const context = createTemplateContext({ + kind, + opts: { + framework, + name: caseName, + client, + packageManager: 'pnpm', + out: '/tmp/not-written', + extensions, + }, + }); + const files = createTemplateFiles(context); + const paths = files.map((file) => file.path); - expect(new Set(paths).size).toBe(paths.length); - expect(paths).toContain('package.json'); - for (const file of files) { - const output = file.render(); - expect(output.length).toBeGreaterThan(0); + expect(new Set(paths).size, caseName).toBe(paths.length); + for (const file of files) { + expect(file.render(), `${caseName}:${file.path}`).not.toBe(''); + } + combinations += 1; + } } } - }, - ); + } + + expect(combinations).toBe(96); + }); it('keeps minimal Node SDK manifests free of browser and extension dependencies', () => { const context = createTemplateContext({ @@ -123,24 +116,6 @@ describe('createTemplateFiles', () => { expect(manifest.dependencies).not.toHaveProperty('@paraspell/swap'); }); - it.each([ - ['react', 'src/main.tsx'], - ['vue', 'src/main.ts'], - ] as const)( - 'shares the %s entrypoint across SDK and API templates', - (framework, mainPath) => { - const extensions = { - evm: false, - swap: false, - snowbridge: false, - }; - const sdk = renderTemplates('sdk', framework, 'papi', extensions); - const api = renderTemplates('api', framework, 'papi', extensions); - - expect(sdk.get(mainPath)).toBe(api.get(mainPath)); - }, - ); - it('omits configuration-specific dead output', () => { const minimalExtensions = { evm: false, @@ -153,17 +128,21 @@ describe('createTemplateFiles', () => { expect(apiReact.has('src/evm/index.ts')).toBe(false); expect(apiReact.has('src/wallet/evm/index.ts')).toBe(false); expect(apiReact.has('src/utils.ts')).toBe(false); - const apiPapiSubmit = apiReact.get('src/submit/submitPapiTransaction.ts'); + const apiPapiSubmit = apiReact.get('src/utils/submitPapiTransaction.ts'); expect(apiPapiSubmit).toContain('tx.signAndSubmit(signer)'); expect(apiPapiSubmit).toContain('throw new Error(message)'); + expect(apiPapiSubmit).toContain('{ cause: error }'); expect(apiPapiSubmit).not.toContain('UnsupportedOperationError'); expect(apiPapiSubmit).not.toContain('signSubmitAndWatch'); - expect(apiReact.get('src/submit/submitEvmTx.ts')).toContain( + expect(apiReact.get('src/utils/submitEvmTx.ts')).toContain( 'walletClient.sendTransaction', ); - expect(apiReact.get('src/submit/submitEvmTx.ts')).not.toContain( + expect(apiReact.get('src/utils/submitEvmTx.ts')).not.toContain( 'createPublicClient', ); + const apiDataHook = apiReact.get('src/hooks/useApiData.ts'); + expect(apiDataHook).toContain('state?.url === url'); + expect(apiDataHook).not.toContain('setLoading'); const minimalPapiReact = renderTemplates( 'sdk', @@ -178,11 +157,11 @@ describe('createTemplateFiles', () => { expect(minimalPapiReact.get('src/hooks/usePapiWallet.ts')).not.toMatch( /return\s*\{[\s\S]*?\bselectedAccount,/, ); - expect(minimalPapiReact.get('src/xcm/papi.ts')).toContain( + expect(minimalPapiReact.get('src/utils/submitUsingSdk.ts')).toContain( 'submitPapiTransaction(tx, signer)', ); const sdkPapiSubmit = minimalPapiReact.get( - 'src/xcm/submitPapiTransaction.ts', + 'src/utils/submitPapiTransaction.ts', ); expect(sdkPapiSubmit).toBe(apiPapiSubmit); @@ -244,12 +223,38 @@ describe('createTemplateFiles', () => { expect(minimalPapiVue.get('src/types.ts')).not.toContain( 'TWalletControlsSubstrateProps', ); + expect(minimalPapiVue.get('src/components/TransferForm.vue')).toContain( + 'import { ref, watch } from "vue";', + ); const fullPapiVue = renderTemplates('sdk', 'vue', 'papi', fullExtensions); expect(fullPapiVue.get('src/types.ts')).not.toContain( 'TWalletControlsEvmProps', ); }); + it.each([ + ['react', 'src/hooks/useWalletWithEvm.ts'], + ['vue', 'src/composables/useWalletWithEvm.ts'], + ] as const)( + 'keeps generated %s SDK submission routing in one place', + (framework, walletPath) => { + const output = renderTemplates('sdk', framework, 'papi', { + evm: true, + swap: true, + snowbridge: true, + }); + const wallet = output.get(walletPath)!; + const submitUsingSdk = output.get('src/utils/submitUsingSdk.ts')!; + + expect(output.has('src/utils/connectWalletAlert.ts')).toBe(true); + expect(output.has('src/utils/submitTransfer.ts')).toBe(false); + expect(wallet).toContain('await submitUsingSdk(formValues, options);'); + expect(wallet).not.toContain('submitEvmIfNeeded'); + expect(submitUsingSdk).toContain('isChainEvm'); + expect(submitUsingSdk).toContain('submitEvmTransferFromForm'); + }, + ); + it('renders the ParaSpell wordmark and dedicated favicon in browser apps', () => { const context = createTemplateContext({ kind: 'api', @@ -272,7 +277,7 @@ describe('createTemplateFiles', () => { }); it.each(['sdk', 'api'] as const)( - 'organizes generated React %s hooks and components behind folder barrels', + 'organizes generated React %s source into components, hooks, and utils', (kind) => { const output = renderTemplates(kind, 'react', 'dedot', { evm: true, @@ -289,6 +294,9 @@ describe('createTemplateFiles', () => { filePath !== 'src/main.tsx' && filePath !== 'src/App.tsx', ); + const utilityPaths = paths.filter((filePath) => + filePath.startsWith('src/utils/'), + ); expect(hookPaths.length).toBeGreaterThan(0); expect( @@ -300,21 +308,20 @@ describe('createTemplateFiles', () => { /^src\/components(?:\/common)?\/[^/]+\.tsx$/.test(filePath), ), ).toBe(true); + expect(utilityPaths.length).toBeGreaterThan(0); expect( - paths.some((filePath) => filePath.startsWith('src/components/common/')), - ).toBe(false); - expect(output.get('src/components/index.ts')).toContain( - 'export { XcmTransfer }', - ); + utilityPaths.every((filePath) => /^src\/utils\/[^/]+$/.test(filePath)), + ).toBe(true); + expect(output.has('src/components/index.ts')).toBe(false); + expect(output.has('src/hooks/index.ts')).toBe(false); expect(output.has('src/components/TransferForm.tsx')).toBe(true); expect(output.has('src/components/XcmTransferForm.tsx')).toBe(false); - expect(output.get('src/hooks/index.ts')).toContain( - 'export { useWalletWithEvm }', - ); expect(output.get('src/main.tsx')).toContain('from "./App.tsx"'); - expect(output.get('src/App.tsx')).toContain('from "./components"'); + expect(output.get('src/App.tsx')).toContain( + 'from "./components/XcmTransfer"', + ); expect(output.get('src/components/XcmTransfer.tsx')).toContain( - 'from "../hooks"', + 'from "../hooks/useWalletWithEvm"', ); if (kind === 'sdk') { expect(output.get('src/components/XcmTransfer.tsx')).toContain( @@ -325,7 +332,7 @@ describe('createTemplateFiles', () => { ); it.each(['sdk', 'api'] as const)( - 'organizes generated Vue %s composables and components conventionally', + 'organizes generated Vue %s source into components, composables, and utils', (kind) => { const output = renderTemplates(kind, 'vue', 'dedot', { evm: true, @@ -339,6 +346,9 @@ describe('createTemplateFiles', () => { const componentPaths = paths.filter( (filePath) => filePath.endsWith('.vue') && filePath !== 'src/App.vue', ); + const utilityPaths = paths.filter((filePath) => + filePath.startsWith('src/utils/'), + ); expect(composablePaths.length).toBeGreaterThan(0); expect( @@ -352,21 +362,53 @@ describe('createTemplateFiles', () => { /^src\/components(?:\/common)?\/[^/]+\.vue$/.test(filePath), ), ).toBe(true); + expect(utilityPaths.length).toBeGreaterThan(0); expect( - paths.some((filePath) => filePath.startsWith('src/components/common/')), - ).toBe(false); - expect(output.get('src/composables/index.ts')).toContain( - 'export { useWalletWithEvm }', - ); - expect(output.get('src/components/index.ts')).toContain( - 'export { default as XcmTransfer }', - ); + utilityPaths.every((filePath) => /^src\/utils\/[^/]+$/.test(filePath)), + ).toBe(true); + expect(output.has('src/components/index.ts')).toBe(false); + expect(output.has('src/composables/index.ts')).toBe(false); expect(output.has('src/components/TransferForm.vue')).toBe(true); expect(output.has('src/components/XcmTransferForm.vue')).toBe(false); - expect(output.get('src/App.vue')).toContain('from "./components"'); + expect(output.get('src/App.vue')).toContain( + 'from "./components/XcmTransfer.vue"', + ); expect(output.get('src/components/XcmTransfer.vue')).toContain( - 'from "../composables"', + 'from "../composables/useWalletWithEvm"', + ); + const transferForm = output.get('src/components/TransferForm.vue')!; + expect(transferForm).toContain('const handleSubmit = () => {'); + expect(transferForm).toContain('

'); + if (kind === 'sdk') { + expect(transferForm).toContain( + 'useCurrencyOptions(originChain, destinationChain, swapEnabled, exchange)', + ); + expect(transferForm).not.toContain('const from = computed'); + expect(transferForm).not.toContain('const to = computed'); + } + const transferComponent = output.get('src/components/XcmTransfer.vue')!; + expect(transferComponent.indexOf('v-model:origin-chain')).toBeLessThan( + transferComponent.indexOf(':loading'), ); }, ); + + it.each(['sdk', 'api'] as const)( + 'keeps generated Node %s source compact and flat', + (kind) => { + const output = renderTemplates(kind, 'node', 'dedot', { + evm: true, + swap: true, + snowbridge: true, + }); + const sourcePaths = [...output.keys()].filter((filePath) => + filePath.startsWith('src/'), + ); + + expect(sourcePaths.length).toBeGreaterThan(0); + expect( + sourcePaths.every((filePath) => /^src\/[^/]+\.ts$/.test(filePath)), + ).toBe(true); + }, + ); }); diff --git a/src/generator/templates/index.ts b/src/generator/templates/index.ts index 50b080a..70af215 100644 --- a/src/generator/templates/index.ts +++ b/src/generator/templates/index.ts @@ -2,23 +2,23 @@ import type { TTemplateContext, TTemplateFile } from '../types.js'; import { createQualityTemplates } from './quality.js'; import { createScaffoldTemplates } from './scaffold.js'; import { createFragmentRenderer } from './shared/index.js'; -import { createXcmApiNodeTemplates } from './xcm-api-node.js'; -import { createXcmApiReactTemplates } from './xcm-api-react.js'; -import { createXcmApiVueTemplates } from './xcm-api-vue.js'; -import { createXcmSdkNodeTemplates } from './xcm-sdk-node.js'; -import { createXcmSdkReactTemplates } from './xcm-sdk-react.js'; -import { createXcmSdkVueTemplates } from './xcm-sdk-vue.js'; +import { createNodeApiTemplates } from './node/api.js'; +import { createNodeSdkTemplates } from './node/sdk.js'; +import { createReactApiTemplates } from './react/api.js'; +import { createReactSdkTemplates } from './react/sdk.js'; +import { createVueApiTemplates } from './vue/api.js'; +import { createVueSdkTemplates } from './vue/sdk.js'; const TEMPLATE_FACTORIES = { api: { - node: createXcmApiNodeTemplates, - react: createXcmApiReactTemplates, - vue: createXcmApiVueTemplates, + node: createNodeApiTemplates, + react: createReactApiTemplates, + vue: createVueApiTemplates, }, sdk: { - node: createXcmSdkNodeTemplates, - react: createXcmSdkReactTemplates, - vue: createXcmSdkVueTemplates, + node: createNodeSdkTemplates, + react: createReactSdkTemplates, + vue: createVueSdkTemplates, }, }; diff --git a/src/generator/templates/xcm-api-node.ts b/src/generator/templates/node/api.ts similarity index 92% rename from src/generator/templates/xcm-api-node.ts rename to src/generator/templates/node/api.ts index aa1db37..044b0c6 100644 --- a/src/generator/templates/xcm-api-node.ts +++ b/src/generator/templates/node/api.ts @@ -1,16 +1,15 @@ -import type { TTemplateContext, TTemplateFile } from '../types.js'; -import { createFragmentFile } from './fragment-file.js'; -import type { TFragmentRenderer } from './shared/fragment-types.js'; -import { source } from './source.js'; +import type { TTemplateContext, TTemplateFile } from '../../types.js'; +import { createFragmentFile } from '../fragment-file.js'; +import type { TFragmentRenderer } from '../shared/fragment-types.js'; +import { source } from '../source.js'; -export const createXcmApiNodeTemplates = ( +export const createNodeApiTemplates = ( context: TTemplateContext, renderFragment: TFragmentRenderer, ): readonly TTemplateFile[] => { const { extensions: { swap }, evmWallet, - defaultOriginChain, } = context; const fragment = createFragmentFile(renderFragment); @@ -45,9 +44,12 @@ export const createXcmApiNodeTemplates = ( const response = await axios.get( \`\${API_URL}/chains/\${apiTx.chain}/ws-endpoints\`, ); - const endpoints = response.data; + const [endpoint] = response.data; + if (!endpoint) { + throw new Error(\`No WebSocket endpoint returned for \${apiTx.chain}.\`); + } - const client = createWsClient(endpoints[0]); + const client = createWsClient(endpoint); try { const callData = Binary.fromHex(apiTx.tx); const tx = await client.getUnsafeApi().txFromCallData(callData); @@ -114,7 +116,7 @@ export const createXcmApiNodeTemplates = ( ${renderFragment('api/buildApiParams')} const defaults: TTransferParams = { - from: "${defaultOriginChain}", + from: "Astar", to: "Hydration", amount: "0.1", recipient: "//Bob", diff --git a/src/generator/templates/xcm-sdk-node.ts b/src/generator/templates/node/sdk.ts similarity index 95% rename from src/generator/templates/xcm-sdk-node.ts rename to src/generator/templates/node/sdk.ts index 3edcfd2..175a741 100644 --- a/src/generator/templates/xcm-sdk-node.ts +++ b/src/generator/templates/node/sdk.ts @@ -1,9 +1,9 @@ -import type { TTemplateContext, TTemplateFile } from '../types.js'; -import { createFragmentFile } from './fragment-file.js'; -import type { TFragmentRenderer } from './shared/fragment-types.js'; -import { source } from './source.js'; +import type { TTemplateContext, TTemplateFile } from '../../types.js'; +import { createFragmentFile } from '../fragment-file.js'; +import type { TFragmentRenderer } from '../shared/fragment-types.js'; +import { source } from '../source.js'; -export const createXcmSdkNodeTemplates = ( +export const createNodeSdkTemplates = ( context: TTemplateContext, renderFragment: TFragmentRenderer, ): readonly TTemplateFile[] => { @@ -12,7 +12,6 @@ export const createXcmSdkNodeTemplates = ( sdkPackage, extensions: { swap }, evmWallet, - defaultOriginChain, } = context; const fragment = createFragmentFile(renderFragment); @@ -167,7 +166,7 @@ export const createXcmSdkNodeTemplates = ( import type { TTransferParams } from "./types.js"; const defaults: TTransferParams = { - from: "${defaultOriginChain}", + from: "Astar", to: "Hydration", amount: "0.1", recipient: "//Bob", diff --git a/src/generator/templates/package-json.test.ts b/src/generator/templates/package-json.test.ts index 15a5394..d62e3c7 100644 --- a/src/generator/templates/package-json.test.ts +++ b/src/generator/templates/package-json.test.ts @@ -17,6 +17,32 @@ type TRenderedManifest = { }; describe('renderPackageJson', () => { + it('classifies framework packages as runtime or development dependencies', () => { + const render = (framework: 'react' | 'vue') => { + const context = createTemplateContext({ + kind: 'api', + opts: { + framework, + name: `${framework}-dependency-test`, + client: 'papi', + packageManager: 'pnpm', + out: '/tmp/not-written', + extensions: { evm: false, swap: false, snowbridge: false }, + }, + }); + return JSON.parse(renderPackageJson(context)) as TRenderedManifest; + }; + + const react = render('react'); + expect(react.dependencies).toHaveProperty('react'); + expect(react.dependencies).toHaveProperty('react-dom'); + expect(react.devDependencies).not.toHaveProperty('react'); + + const vue = render('vue'); + expect(vue.dependencies).toHaveProperty('vue'); + expect(vue.devDependencies).toHaveProperty('@vue/tsconfig'); + }); + it('uses every canonical template dependency at its configured version', () => { const emittedPackages = new Set(); const extensions = { evm: true, swap: true, snowbridge: true }; diff --git a/src/generator/templates/package-json.ts b/src/generator/templates/package-json.ts index becdd64..448f466 100644 --- a/src/generator/templates/package-json.ts +++ b/src/generator/templates/package-json.ts @@ -11,27 +11,64 @@ type TPackageJson = { devDependencies: Record; }; -const qualityScripts = { +type TPackageJsonBase = Pick< + TPackageJson, + 'name' | 'private' | 'version' | 'type' +>; + +const QUALITY_SCRIPTS = { lint: 'eslint . --max-warnings 0', 'lint:fix': 'eslint . --fix', format: 'prettier . --write', 'format:check': 'prettier . --check', }; -const qualityDependencies = (): Record => - dependencyVersions( - '@eslint/js', - 'eslint', - 'eslint-config-prettier', - 'globals', - 'prettier', - 'typescript', - 'typescript-eslint', - ); +const QUALITY_DEPENDENCIES = dependencyVersions( + '@eslint/js', + 'eslint', + 'eslint-config-prettier', + 'globals', + 'prettier', + 'typescript', + 'typescript-eslint', +); + +const baseManifest = (context: TTemplateContext): TPackageJsonBase => ({ + name: context.projectName, + private: true, + version: '1.0.0', + type: 'module', +}); + +const sdkClientDependencies = ( + context: TTemplateContext, +): Record => { + const isNode = context.framework === 'node'; + + switch (context.client) { + case 'papi': + return dependencyVersions('@paraspell/descriptors', 'polkadot-api'); + case 'pjs': + return isNode + ? dependencyVersions( + '@polkadot/api', + '@polkadot/types', + '@polkadot/util', + ) + : dependencyVersions('@polkadot/api', '@polkadot/extension-dapp'); + case 'dedot': + return isNode + ? dependencyVersions('dedot') + : dependencyVersions( + 'dedot', + '@polkadot/api', + '@polkadot/extension-dapp', + ); + } +}; const sdkDependencies = (context: TTemplateContext): Record => { const { - client, framework, sdkPackage, extensions: { evm, snowbridge, swap }, @@ -40,7 +77,7 @@ const sdkDependencies = (context: TTemplateContext): Record => { return { ...dependencyVersions(sdkPackage), - ...(client === 'papi' ? dependencyVersions('@paraspell/descriptors') : {}), + ...sdkClientDependencies(context), ...(swap ? dependencyVersions('@paraspell/swap') : {}), ...(evm ? dependencyVersions('@paraspell/evm') : {}), ...(snowbridge ? dependencyVersions('@paraspell/evm-snowbridge') : {}), @@ -49,100 +86,78 @@ const sdkDependencies = (context: TTemplateContext): Record => { ? dependencyVersions('viem') : dependencyVersions('mipd', 'viem') : {}), - ...(client === 'papi' ? dependencyVersions('polkadot-api') : {}), - ...(client === 'pjs' - ? framework === 'node' - ? dependencyVersions( - '@polkadot/api', - '@polkadot/types', - '@polkadot/util', - ) - : dependencyVersions('@polkadot/api', '@polkadot/extension-dapp') - : {}), - ...(client === 'dedot' - ? framework === 'node' - ? dependencyVersions('dedot') - : dependencyVersions( - 'dedot', - '@polkadot/api', - '@polkadot/extension-dapp', - ) - : {}), }; }; const browserManifest = (context: TTemplateContext): TPackageJson => { - const react = context.framework === 'react'; - const sdk = context.projectKind === 'sdk'; + const isReact = context.framework === 'react'; + const isSdk = context.projectKind === 'sdk'; return { - name: context.projectName, - private: true, - version: '1.0.0', - type: 'module', + ...baseManifest(context), scripts: { dev: 'vite', - build: react ? 'tsc -b && vite build' : 'vue-tsc --noEmit && vite build', - compile: react ? 'tsc -b --noEmit' : 'vue-tsc --noEmit', - ...qualityScripts, + build: isReact + ? 'tsc -b && vite build' + : 'vue-tsc --noEmit && vite build', + compile: isReact ? 'tsc -b --noEmit' : 'vue-tsc --noEmit', + ...QUALITY_SCRIPTS, preview: 'vite preview', }, dependencies: { - ...(sdk + ...(isSdk ? sdkDependencies(context) : { ...dependencyVersions('axios', 'polkadot-api'), ...(context.evmWallet ? dependencyVersions('mipd', 'viem') : {}), }), - ...(react ? {} : dependencyVersions('vue')), + ...(isReact + ? dependencyVersions('react', 'react-dom') + : dependencyVersions('vue')), }, devDependencies: { - ...qualityDependencies(), - ...dependencyVersions('vite'), - ...(react + ...QUALITY_DEPENDENCIES, + ...dependencyVersions('@types/node', 'vite'), + ...(isReact ? dependencyVersions( '@types/react', '@types/react-dom', '@vitejs/plugin-react', 'eslint-plugin-react-hooks', 'eslint-plugin-react-refresh', - 'react', - 'react-dom', ) : dependencyVersions( '@vitejs/plugin-vue', + '@vue/tsconfig', 'eslint-plugin-vue', 'vue-eslint-parser', 'vue-tsc', )), - ...(sdk ? dependencyVersions('vite-plugin-wasm') : {}), + ...(isSdk ? dependencyVersions('vite-plugin-wasm') : {}), }, }; }; const nodeManifest = (context: TTemplateContext): TPackageJson => { - const sdk = context.projectKind === 'sdk'; + const isSdk = context.projectKind === 'sdk'; return { - name: context.projectName, - private: true, - version: '1.0.0', - type: 'module', + ...baseManifest(context), scripts: { start: 'tsx src/index.ts', build: 'tsc', compile: 'tsc --noEmit', - ...qualityScripts, + ...QUALITY_SCRIPTS, }, dependencies: { - ...(sdk + ...(isSdk ? sdkDependencies(context) : dependencyVersions('axios', 'polkadot-api')), ...dependencyVersions('@polkadot/keyring', 'dotenv', 'express'), - ...(!sdk && context.evmWallet ? dependencyVersions('viem') : {}), + ...(!isSdk && context.evmWallet ? dependencyVersions('viem') : {}), }, devDependencies: { - ...qualityDependencies(), + ...QUALITY_DEPENDENCIES, ...dependencyVersions('@types/express', '@types/node', 'tsx'), }, }; diff --git a/src/generator/templates/quality.ts b/src/generator/templates/quality.ts index 1ba8b4e..4116101 100644 --- a/src/generator/templates/quality.ts +++ b/src/generator/templates/quality.ts @@ -24,7 +24,7 @@ const renderReactEslintConfig = () => source` extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], languageOptions: { - ecmaVersion: 2020, + ecmaVersion: 2023, globals: globals.browser, }, plugins: { @@ -59,7 +59,7 @@ const renderVueEslintConfig = () => source` { files: ["**/*.{ts,vue}"], languageOptions: { - ecmaVersion: 2020, + ecmaVersion: 2023, globals: globals.browser, }, }, @@ -89,7 +89,7 @@ const renderNodeEslintConfig = () => source` extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.ts"], languageOptions: { - ecmaVersion: 2022, + ecmaVersion: 2023, globals: globals.node, }, }, diff --git a/src/generator/templates/xcm-api-react.ts b/src/generator/templates/react/api.ts similarity index 87% rename from src/generator/templates/xcm-api-react.ts rename to src/generator/templates/react/api.ts index f7f0daa..20e9fcb 100644 --- a/src/generator/templates/xcm-api-react.ts +++ b/src/generator/templates/react/api.ts @@ -1,11 +1,10 @@ -import type { TTemplateContext, TTemplateFile } from '../types.js'; -import { createFragmentFile } from './fragment-file.js'; -import type { TFragmentRenderer } from './shared/fragment-types.js'; -import { createSpaBarrelTemplates } from './spa-barrels.js'; -import { createSpaToolingTemplates } from './spa-tooling.js'; -import { source } from './source.js'; +import type { TTemplateContext, TTemplateFile } from '../../types.js'; +import { createFragmentFile } from '../fragment-file.js'; +import type { TFragmentRenderer } from '../shared/fragment-types.js'; +import { createSpaToolingTemplates } from '../spa-tooling.js'; +import { source } from '../source.js'; -export const createXcmApiReactTemplates = ( +export const createReactApiTemplates = ( context: TTemplateContext, renderFragment: TFragmentRenderer, ): readonly TTemplateFile[] => { @@ -20,7 +19,7 @@ export const createXcmApiReactTemplates = ( { path: 'src/App.tsx', render: () => source`import "./App.css"; - import { XcmTransfer } from "./components"; + import { XcmTransfer } from "./components/XcmTransfer"; const App = () => ( <> @@ -60,22 +59,21 @@ export const createXcmApiReactTemplates = ( render: () => source`import { useState, type FC } from "react"; import { TransferForm } from "./TransferForm"; import type { TFormValues } from "../types"; + import { toError } from "../utils/toError"; ${ evmWallet ? source` import { WalletControls } from "./WalletControls"; import { WalletKindSelector } from "./WalletKindSelector"; - import { useWalletWithEvm } from "../hooks"; + import { useWalletWithEvm } from "../hooks/useWalletWithEvm"; ` : source` import { SubstrateWalletControls } from "./SubstrateWalletControls"; - import { usePapiWallet } from "../hooks"; - import { submitUsingApi } from "../submit/submitUsingApi"; + import { usePapiWallet } from "../hooks/usePapiWallet"; + import { submitUsingApi } from "../utils/submitUsingApi"; ` } - ${renderFragment('spa/toError')} export const XcmTransfer: FC = () => { - const [errorVisible, setErrorVisible] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -93,7 +91,7 @@ export const createXcmApiReactTemplates = ( const onSubmit = async (formValues: TFormValues) => { setLoading(true); - setErrorVisible(false); + setError(null); try { ${ @@ -118,7 +116,6 @@ export const createXcmApiReactTemplates = ( alert("Transaction was successful!"); } catch (error) { setError(toError(error)); - setErrorVisible(true); } finally { setLoading(false); } @@ -161,7 +158,11 @@ export const createXcmApiReactTemplates = ( originChain={originChain} onOriginChange={setOriginChain} /> - {errorVisible &&

{error?.message}

} + {error && ( +

+ {error.message} +

+ )} ); }; @@ -170,14 +171,19 @@ export const createXcmApiReactTemplates = ( }, { path: 'src/components/TransferForm.tsx', - render: - () => source`import { useState, useMemo, FormEvent, FC } from "react"; - import { API_URL } from "../consts"; - import { useApiData } from "../hooks"; + render: () => source`import { + useMemo, + useState, + ${swap ? source`type ChangeEvent,` : ''} + type FC, + type FormEvent, + } from "react"; + import { useApiData } from "../hooks/useApiData"; + import { API_URL } from "../utils/constants"; import type { TAssetInfo, TFormValues } from "../types";${ swap ? source` - import { useExchangeChains } from "../hooks";` + import { useExchangeChains } from "../hooks/useExchangeChains";` : '' } @@ -260,7 +266,7 @@ export const createXcmApiReactTemplates = ( ? currencyToLocation : currencyToOptions.at(0)?.value; - const handleExchangeChange = (e: React.ChangeEvent) => { + const handleExchangeChange = (e: ChangeEvent) => { const selected = Array.from(e.target.selectedOptions, (o) => o.value); const exchanges = selected.filter((value) => value !== AUTO_EXCHANGE_VALUE); setExchange(exchanges.length > 0 ? exchanges : []); @@ -313,7 +319,9 @@ export const createXcmApiReactTemplates = ( return ( {dataError && ( -

Could not load options: {dataError.message}

+

+ Could not load options: {dataError.message} +

)}