diff --git a/packages/@coorpacademy-components/scripts/observables/cleanup.js b/packages/@coorpacademy-components/scripts/observables/cleanup.js new file mode 100644 index 0000000000..284a34342e --- /dev/null +++ b/packages/@coorpacademy-components/scripts/observables/cleanup.js @@ -0,0 +1,67 @@ +const {readdir, rmdir, stat} = require('fs'); +const {dirname} = require('path'); +const {rm} = require('fs/promises'); +const {Observable} = require('rxjs'); +const {filter} = require('rxjs/operators'); +const {walkDirectory$} = require('./walk-directory'); // your existing directory walker + +const readAllStoryFiles$ = cwd => + walkDirectory$(cwd).pipe(filter(filePath => filePath.endsWith('.stories.tsx'))); + +const removeEmptyFolders$ = (folderPath, removeParents = false) => { + return new Observable(observer => { + stat(folderPath, (statErr, stats) => { + if (statErr || !stats.isDirectory()) { + observer.complete(); + return; + } + readdir(folderPath, (readErr, files) => { + if (readErr) { + observer.error(readErr); + return; + } + // Filter out system files if needed + const files_ = files.filter(file => file !== '.DS_Store'); + + if (files_.length === 0) { + rmdir(folderPath, rmdirErr => { + if (rmdirErr) { + observer.error(rmdirErr); + return; + } + observer.next(folderPath); + if (removeParents && dirname(folderPath) !== folderPath) { + removeEmptyFolders$(dirname(folderPath), true).subscribe( + val => observer.next(val), + err => observer.error(err), + () => observer.complete() + ); + } else { + observer.complete(); + } + }); + } else { + // Not empty + observer.complete(); + } + }); + }); + }); +}; + +/** + * Recursively deletes a folder (rm -rf). + */ +const deleteFolder$ = folderPath => { + return new Observable(observer => { + rm(folderPath, {recursive: true, force: true}) + .then(() => { + observer.next(folderPath); + observer.complete(); + return null; + }) + .catch(err => observer.error(err)); + }); +}; + +module.exports = {readAllStoryFiles$, deleteFolder$, removeEmptyFolders$}; diff --git a/packages/@coorpacademy-components/scripts/observables/generate-stories.js b/packages/@coorpacademy-components/scripts/observables/generate-stories.js index b7a027182b..f0cd6e5dfb 100644 --- a/packages/@coorpacademy-components/scripts/observables/generate-stories.js +++ b/packages/@coorpacademy-components/scripts/observables/generate-stories.js @@ -1,43 +1,85 @@ -const {join, relative} = require('path'); -const {map} = require('rxjs/operators'); -const {concat, of} = require('rxjs'); +const {join, dirname, relative} = require('path'); +const {of, from, concat} = require('rxjs'); +const {map, toArray, mergeMap, shareReplay} = require('rxjs/operators'); const {readComponentFixtures$} = require('./component-fixtures'); const {pascalCase} = require('./string'); const {readComponents$} = require('./components'); +const {readAllStoryFiles$, deleteFolder$, removeEmptyFolders$} = require('./cleanup'); -const generateStories$ = cwd => - readComponents$(cwd).pipe( +/** + * Generate story files for all current components. + */ +const generateStories$ = cwd => { + // 1) Build [storiesPath, lines$] pairs for each component + const generation$ = readComponents$(cwd).pipe( map(({title, path, type, titleRaw, levels}) => { const testPath = join(path, 'test'); const storiesPath = join(testPath, 'index.stories.tsx'); - return [ - storiesPath, - concat( - of(`import React from 'react';`, `import ${title} from '..';`), - readComponentFixtures$({title, path, type}).pipe( - map( - ({fixture, fixturePath}) => - `import fixture${fixture} from './${relative(testPath, fixturePath)}';` - ) - ), - of( - ` -export default { - title: '${[...levels.map(pascalCase), titleRaw].join('/')}', - component: ${title} -};` - ), - readComponentFixtures$({title, path, type}).pipe( - map( - ({fixture}) => - ` + + // 1) Read the fixtures once, share the results + const fixtures$ = readComponentFixtures$({title, path, type}).pipe( + shareReplay({bufferSize: Infinity, refCount: true}) + // caches and replays the emitted fixtures + ); + + const fixtureImports$ = fixtures$.pipe( + map( + ({fixture, fixturePath}) => + `import fixture${fixture} from './${relative(testPath, fixturePath)}';` + ) + ); + + const fixtureExports$ = fixtures$.pipe( + map( + ({fixture}) => ` export const ${pascalCase(fixture)} = (args: any) => <${title} {...args} />; ${pascalCase(fixture)}.args = fixture${fixture}.props;` - ) - ) ) - ]; + ); + + const content$ = concat( + // 1) Basic imports + of(`import React from 'react';`, `import ${title} from '..';`), + // 2) Fixture imports + fixtureImports$, + // 3) Default export + of(` +export default { + title: '${[...levels.map(pascalCase), titleRaw].join('/')}', + component: ${title} +};`), + // 4) Fixture exports + fixtureExports$ + ); + + return [storiesPath, content$]; + }), + toArray() + ); + + // 2) Remove stale story folders, then emit the new generation array + return generation$.pipe( + mergeMap(storyEntries => { + const desiredStoryPaths = new Set(storyEntries.map(([p]) => p)); + + return readAllStoryFiles$(cwd).pipe( + mergeMap(existingStoryPath => { + if (!desiredStoryPaths.has(existingStoryPath)) { + // This story is stale + const testFolder = dirname(existingStoryPath); + const parentFolder = dirname(testFolder); + return deleteFolder$(testFolder).pipe( + mergeMap(() => removeEmptyFolders$(parentFolder, true)) + ); + } + return of(null); + }), + toArray(), + // 3) Finally, emit the storyEntries array for the rest of the pipeline (writing files, etc.) + mergeMap(() => from(storyEntries)) + ); }) ); +}; -module.exports.generateStories$ = generateStories$; +module.exports = {generateStories$};