diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/__tests__/copy-readme-to-output.test.ts b/packages/twenty-sdk/src/cli/utilities/build/common/__tests__/copy-readme-to-output.test.ts new file mode 100644 index 0000000000..87de480e15 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/__tests__/copy-readme-to-output.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { OUTPUT_DIR } from 'twenty-shared/application'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + copyReadmeToOutput, + findReadmeFileName, +} from '@/cli/utilities/build/common/copy-readme-to-output'; +import { pathExists } from '@/cli/utilities/file/fs-utils'; + +describe('findReadmeFileName', () => { + it('should match readme files regardless of case', () => { + expect(findReadmeFileName(['README.md'])).toBe('README.md'); + expect(findReadmeFileName(['readme.md'])).toBe('readme.md'); + expect(findReadmeFileName(['Readme.txt'])).toBe('Readme.txt'); + expect(findReadmeFileName(['README'])).toBe('README'); + }); + + it('should prefer the markdown readme over other variants', () => { + expect(findReadmeFileName(['README.txt', 'README.md'])).toBe('README.md'); + }); + + it('should ignore unrelated files', () => { + expect( + findReadmeFileName(['index.ts', 'package.json', 'readme-notes.md']), + ).toBeUndefined(); + }); +}); + +describe('copyReadmeToOutput', () => { + let appPath: string; + + beforeEach(async () => { + appPath = await mkdtemp(join(tmpdir(), 'readme-test-')); + }); + + afterEach(async () => { + await rm(appPath, { recursive: true, force: true }); + }); + + it('should copy the readme into the output directory', async () => { + await writeFile(join(appPath, 'README.md'), '# My App', 'utf-8'); + + await copyReadmeToOutput(appPath); + + const copiedReadme = await readFile( + join(appPath, OUTPUT_DIR, 'README.md'), + 'utf-8', + ); + + expect(copiedReadme).toBe('# My App'); + }); + + it('should do nothing when the app has no readme', async () => { + await writeFile(join(appPath, 'package.json'), '{}', 'utf-8'); + + await copyReadmeToOutput(appPath); + + expect(await pathExists(join(appPath, OUTPUT_DIR))).toBe(false); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts index e30b972e82..b24a05e84d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/build-application.ts @@ -8,6 +8,7 @@ import { } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; +import { copyReadmeToOutput } from '@/cli/utilities/build/common/copy-readme-to-output'; import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type'; import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build'; import { LOGIC_FUNCTION_EXTERNAL_MODULES } from '@/cli/utilities/build/common/esbuild-watcher'; @@ -153,6 +154,8 @@ export const buildApplication = async ( }); } + await copyReadmeToOutput(options.appPath); + return { builtFileInfos }; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/copy-readme-to-output.ts b/packages/twenty-sdk/src/cli/utilities/build/common/copy-readme-to-output.ts new file mode 100644 index 0000000000..6a86d8b7d5 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/copy-readme-to-output.ts @@ -0,0 +1,35 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { OUTPUT_DIR } from 'twenty-shared/application'; + +import { copy, ensureDir } from '@/cli/utilities/file/fs-utils'; + +// npm only ships a README when the file lives in the package root, so the +// app's readme has to be copied into the build output that gets published. +const README_FILE_NAME_REGEX = /^readme(\.[^.]+)?$/i; + +export const findReadmeFileName = ( + entries: string[], +): string | undefined => { + const readmeFileNames = entries.filter((entry) => + README_FILE_NAME_REGEX.test(entry), + ); + + // Prefer markdown, matching how npm ranks README candidates. + return ( + readmeFileNames.find((entry) => /\.md$/i.test(entry)) ?? readmeFileNames[0] + ); +}; + +export const copyReadmeToOutput = async (appPath: string): Promise => { + const readmeFileName = findReadmeFileName(await readdir(appPath)); + + if (readmeFileName === undefined) { + return; + } + + const outputDir = join(appPath, OUTPUT_DIR); + + await ensureDir(outputDir); + await copy(join(appPath, readmeFileName), join(outputDir, readmeFileName)); +};