diff --git a/apps/kazam/package.json b/apps/kazam/package.json index 8a45741..7f12bd8 100644 --- a/apps/kazam/package.json +++ b/apps/kazam/package.json @@ -28,6 +28,7 @@ "@whitebird/kazam-transformer-base": "workspace:*", "c12": "^1.4.2", "chalk": "^4", + "chokidar": "^3.5.3", "commander": "^10.0.1", "glob": "10.1.0", "ink": "^4.4.1", diff --git a/apps/kazam/src/adapters/cli/commands/generate.tsx b/apps/kazam/src/adapters/cli/commands/generate.tsx index b61b412..18264fe 100644 --- a/apps/kazam/src/adapters/cli/commands/generate.tsx +++ b/apps/kazam/src/adapters/cli/commands/generate.tsx @@ -6,6 +6,7 @@ import { type AppProps, render } from 'ink' import React from 'react' import { generate } from '../../../application/usecases/generate' +import { generateWatch } from '../../../application/usecases/generate-watch' import type { KazamConfig } from '../../../types/kazam-config' import { GenerateView } from '../views/generate' @@ -18,6 +19,7 @@ export const generateCommand = new Command() return path }) + .option('-w, --watch', 'Watch for changes and regenerate code') .action(async (options) => { let exit: AppProps['exit'] | undefined const setExit = (_exit: AppProps['exit']) => exit = _exit @@ -30,11 +32,12 @@ export const generateCommand = new Command() if (config === null || configFile === undefined) throw new Error('Could not load config') - const generatePromise = generate(config, configFile, fs) + const generatePromise = options.watch === true + ? generateWatch(config, configFile, fs) + : generate(config, configFile, fs) render( , ) diff --git a/apps/kazam/src/adapters/cli/types/Commander.d.ts b/apps/kazam/src/adapters/cli/types/Commander.d.ts index fef67b9..c55a0be 100644 --- a/apps/kazam/src/adapters/cli/types/Commander.d.ts +++ b/apps/kazam/src/adapters/cli/types/Commander.d.ts @@ -1,3 +1,3 @@ -// declare module 'commander' { -// export * from '@commander-js/extra-typings' -// } +declare module 'commander' { + export * from '@commander-js/extra-typings' +} diff --git a/apps/kazam/src/adapters/cli/views/generate.tsx b/apps/kazam/src/adapters/cli/views/generate.tsx index 29d2365..f41078c 100644 --- a/apps/kazam/src/adapters/cli/views/generate.tsx +++ b/apps/kazam/src/adapters/cli/views/generate.tsx @@ -1,15 +1,14 @@ import path from 'node:path' import { type AppProps, Text, useApp } from 'ink' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useLayoutEffect, useState } from 'react' -import type { generate } from '../../../application/usecases/generate' import { generateEvents } from '../../../core/events/generate' import { Spinner } from '../components/spinner' export const GenerateView = ( - { generatePromise, setExit }: - { generatePromise: ReturnType; setExit?: (exit: AppProps['exit']) => void }, + { setExit }: + { setExit?: (exit: AppProps['exit']) => void }, ) => { const { exit } = useApp() @@ -17,24 +16,39 @@ export const GenerateView = ( | { success: true } | { error: string } | { pending: true } - >({ pending: true }) + >( + generateEvents.hasReceived('success') + ? { success: true } + : generateEvents.hasReceived('error') + ? { error: generateEvents.getLatestReceived('error')!.message } + : { pending: true }, + ) const [writtenPaths, setWrittenPaths] = useState([]) - useEffect(() => { - setExit?.(exit) + useLayoutEffect(() => { + generateEvents.on('pending', () => { + setStatus({ pending: true }) + setWrittenPaths([]) + }) - generatePromise - .then(() => setStatus({ success: true })) - .catch((error) => { - setStatus({ error }) - console.error('ERROR', error) - }) + generateEvents.on('success', () => { + setStatus({ success: true }) + }) + + generateEvents.on('error', (error) => { + setStatus({ error: error.message }) + console.error('ERROR', error) + }) generateEvents.on('file-written', filePath => setWrittenPaths(writtenPaths => [...writtenPaths, filePath]), ) }, []) + useEffect(() => { + setExit?.(exit) + }, []) + if ('success' in status) { return <> Successfully generated components diff --git a/apps/kazam/src/application/usecases/generate-watch.ts b/apps/kazam/src/application/usecases/generate-watch.ts new file mode 100644 index 0000000..0c42e90 --- /dev/null +++ b/apps/kazam/src/application/usecases/generate-watch.ts @@ -0,0 +1,32 @@ +import chokidar from 'chokidar' + +import { generate } from './generate' + +const getFilesToWatch = ([config, configPath]: Parameters): string[] => { + if (!Array.isArray(config)) + config = [config] + + return [ + ...config.flatMap(config => config.input), + configPath, + ] +} + +export const generateWatch = (...argsGenerate: Parameters) => { + return new Promise((_resolve, reject) => { + // Generate once before watching + generate(...argsGenerate) + + const watcher = chokidar.watch(getFilesToWatch(argsGenerate), { + persistent: true, + }) + + watcher.on('change', async () => { + await generate(...argsGenerate) + }) + + watcher.on('error', (error) => { + reject(error) + }) + }) +} diff --git a/apps/kazam/src/application/usecases/generate.ts b/apps/kazam/src/application/usecases/generate.ts index fdfa0d2..0c803c3 100644 --- a/apps/kazam/src/application/usecases/generate.ts +++ b/apps/kazam/src/application/usecases/generate.ts @@ -120,6 +120,8 @@ export const generate = async ( configPath: string, fileSystem?: typeof fs | undefined, ) => { + generateEvents.emit('pending', undefined) + if (!Array.isArray(config)) config = [config] @@ -127,6 +129,14 @@ export const generate = async ( config.map(config => generateForConfig(config, configPath, fileSystem)), ) .then(results => results.flat()) + .then((results) => { + generateEvents.emit('success', undefined) + return results + }) + .catch((error) => { + generateEvents.emit('error', error) + throw error + }) return results } diff --git a/apps/kazam/src/core/events/generate.ts b/apps/kazam/src/core/events/generate.ts index 18c6b10..b7865cf 100644 --- a/apps/kazam/src/core/events/generate.ts +++ b/apps/kazam/src/core/events/generate.ts @@ -1,6 +1,9 @@ import { registerEventEmitter } from '../lib/typed-event-emitter' const generateEvents = registerEventEmitter<{ + 'pending': void + 'success': void + 'error': Error 'file-written': string }>() diff --git a/apps/kazam/src/core/lib/typed-event-emitter.ts b/apps/kazam/src/core/lib/typed-event-emitter.ts index 585494f..5736663 100644 --- a/apps/kazam/src/core/lib/typed-event-emitter.ts +++ b/apps/kazam/src/core/lib/typed-event-emitter.ts @@ -3,7 +3,14 @@ import { EventEmitter } from 'node:events' class ReturnedEmitter> { #eventEmitter = new EventEmitter() + #receivedEvents: { [EventName in keyof Events]?: Events[EventName][] } = {} + emit(eventName: EventName extends string ? EventName : never, data: Events[EventName]) { + if (!this.#receivedEvents[eventName]) + this.#receivedEvents[eventName] = [] + + this.#receivedEvents[eventName]?.push(data as never) + return this.#eventEmitter.emit(eventName, data) } @@ -16,6 +23,14 @@ class ReturnedEmitter> { this.#eventEmitter.once(eventName, listener) return this } + + hasReceived(eventName: EventName extends string ? EventName : never): boolean { + return eventName in this.#receivedEvents + } + + getLatestReceived(eventName: EventName extends string ? EventName : never): Events[EventName] | undefined { + return this.#receivedEvents[eventName]?.at(-1) + } } export const registerEventEmitter = >() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a7b619..2e3ec57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: chalk: specifier: ^4 version: 4.1.2 + chokidar: + specifier: ^3.5.3 + version: 3.5.3 commander: specifier: ^10.0.1 version: 10.0.1 @@ -283,7 +286,7 @@ importers: version: 7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2) '@storybook/react-vite': specifier: ^7.5.1 - version: 7.5.1(patch_hash=gdcv4ovpvjyowkf3z6yzn7rrom)(react-dom@18.2.0)(react@18.2.0)(rollup@3.29.3)(typescript@5.2.2)(vite@4.4.9) + version: 7.5.1(patch_hash=gdcv4ovpvjyowkf3z6yzn7rrom)(react-dom@18.2.0)(react@18.2.0)(rollup@3.29.3)(typescript@5.2.2)(vite@4.5.0) '@storybook/testing-library': specifier: ^0.2.2 version: 0.2.2 @@ -3151,6 +3154,24 @@ packages: react-docgen-typescript: 2.2.2(typescript@5.2.2) typescript: 5.2.2 vite: 4.4.9(@types/node@18.18.0) + dev: false + + /@joshwooding/vite-plugin-react-docgen-typescript@0.3.0(typescript@5.2.2)(vite@4.5.0): + resolution: {integrity: sha512-2D6y7fNvFmsLmRt6UCOFJPvFoPMJGT0Uh1Wg0RaigUp7kdQPs6yYn8Dmx6GZkOH/NW0yMTwRz/p0SRMMRo50vA==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + glob: 7.2.3 + glob-promise: 4.2.2(glob@7.2.3) + magic-string: 0.27.0 + react-docgen-typescript: 2.2.2(typescript@5.2.2) + typescript: 5.2.2 + vite: 4.5.0(@types/node@20.4.7) + dev: true /@jridgewell/gen-mapping@0.3.3: resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} @@ -4468,6 +4489,45 @@ packages: transitivePeerDependencies: - encoding - supports-color + dev: false + + /@storybook/builder-vite@7.5.1(typescript@5.2.2)(vite@4.5.0): + resolution: {integrity: sha512-fsF4LsxroVvjBJoI5AvRA6euhpYrb5euii5kPzrsWXLOn6gDBK0jQ0looep/io7J45MisDjRTPp14A02pi1bkw==} + peerDependencies: + '@preact/preset-vite': '*' + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + vite-plugin-glimmerx: '*' + peerDependenciesMeta: + '@preact/preset-vite': + optional: true + typescript: + optional: true + vite-plugin-glimmerx: + optional: true + dependencies: + '@storybook/channels': 7.5.1 + '@storybook/client-logger': 7.5.1 + '@storybook/core-common': 7.5.1 + '@storybook/csf-plugin': 7.5.1 + '@storybook/node-logger': 7.5.1 + '@storybook/preview': 7.5.1 + '@storybook/preview-api': 7.5.1 + '@storybook/types': 7.5.1 + '@types/find-cache-dir': 3.2.1 + browser-assert: 1.2.1 + es-module-lexer: 0.9.3 + express: 4.18.2 + find-cache-dir: 3.3.2 + fs-extra: 11.1.1 + magic-string: 0.30.3 + rollup: 3.29.3 + typescript: 5.2.2 + vite: 4.5.0(@types/node@20.4.7) + transitivePeerDependencies: + - encoding + - supports-color + dev: true /@storybook/channels@7.5.1: resolution: {integrity: sha512-7hTGHqvtdFTqRx8LuCznOpqPBYfUeMUt/0IIp7SFuZT585yMPxrYoaK//QmLEWnPb80B8HVTSQi7caUkJb32LA==} @@ -4825,6 +4885,35 @@ packages: - supports-color - typescript - vite-plugin-glimmerx + dev: false + patched: true + + /@storybook/react-vite@7.5.1(patch_hash=gdcv4ovpvjyowkf3z6yzn7rrom)(react-dom@18.2.0)(react@18.2.0)(rollup@3.29.3)(typescript@5.2.2)(vite@4.5.0): + resolution: {integrity: sha512-996/CtOqTjDWMKBGcHG8pwIVlORnoknLD+OTkPXl+aAl9oM9jUtc7psVKLJKGHSHTlVElM2wMTwIHnJ4yeP7bw==} + engines: {node: '>=16'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.2.2)(vite@4.5.0) + '@rollup/pluginutils': 5.0.4(rollup@3.29.3) + '@storybook/builder-vite': 7.5.1(typescript@5.2.2)(vite@4.5.0) + '@storybook/react': 7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2) + '@vitejs/plugin-react': 3.1.0(vite@4.5.0) + magic-string: 0.30.3 + react: 18.2.0 + react-docgen: 6.0.4 + react-dom: 18.2.0(react@18.2.0) + vite: 4.5.0(@types/node@20.4.7) + transitivePeerDependencies: + - '@preact/preset-vite' + - encoding + - rollup + - supports-color + - typescript + - vite-plugin-glimmerx + dev: true patched: true /@storybook/react@7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2): @@ -5580,6 +5669,23 @@ packages: vite: 4.4.9(@types/node@18.18.0) transitivePeerDependencies: - supports-color + dev: false + + /@vitejs/plugin-react@3.1.0(vite@4.5.0): + resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.1.0-beta.0 + dependencies: + '@babel/core': 7.23.0 + '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.23.0) + '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.0) + magic-string: 0.27.0 + react-refresh: 0.14.0 + vite: 4.5.0(@types/node@20.4.7) + transitivePeerDependencies: + - supports-color + dev: true /@vitejs/plugin-vue@4.3.4(vite@4.4.9)(vue@3.3.4): resolution: {integrity: sha512-ciXNIHKPriERBisHFBvnTbfKa6r9SAesOYXeGDzgegcvy9Q4xdScSHAmKbNT0M3O0S9LKhIf5/G+UYG4NnnzYw==} @@ -12023,6 +12129,15 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + /prebuild-install@7.1.1: resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} engines: {node: '>=10'} @@ -12810,6 +12925,14 @@ packages: optionalDependencies: fsevents: 2.3.3 + /rollup@3.29.4: + resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.3 + dev: true + /rollup@4.0.2: resolution: {integrity: sha512-MCScu4usMPCeVFaiLcgMDaBQeYi1z6vpWxz0r0hq0Hv77Y2YuOTZldkuNJ54BdYBH3e+nkrk6j0Rre/NLDBYzg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -14430,6 +14553,42 @@ packages: fsevents: 2.3.3 dev: false + /vite@4.5.0(@types/node@20.4.7): + resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.4.7 + esbuild: 0.18.20 + postcss: 8.4.31 + rollup: 3.29.4 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vitefu@0.2.4(vite@4.4.9): resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} peerDependencies: