diff --git a/jest.config.js b/jest.config.js index 95c919af142c..1cab98b71400 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,7 @@ const config = { coverageReporters: ['text', 'lcov', 'json-summary'], coverageDirectory: 'coverage', collectCoverageFrom: ['scripts/**/*.ts'], - coveragePathIgnorePatterns: ['scripts/compose.ts', 'scripts/tools/categorylist.ts', 'scripts/tools/tags-color.ts'], + coveragePathIgnorePatterns: ['scripts/tools/categorylist.ts', 'scripts/tools/tags-color.ts'], testMatch: ['**/tests/**/*.test.*', '!**/netlify/**/*.test.*'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'] }; diff --git a/scripts/compose.ts b/scripts/compose.ts index cec446e736cd..36159570cee3 100644 --- a/scripts/compose.ts +++ b/scripts/compose.ts @@ -2,10 +2,12 @@ * Script based on https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/scripts/compose.js */ +import { fileURLToPath } from 'node:url'; + +import dayjs from 'dayjs'; import dedent from 'dedent'; import fs from 'fs'; import inquirer from 'inquirer'; -import dayjs from 'dayjs'; import { logger } from './helpers/logger'; @@ -20,18 +22,35 @@ type ComposePromptType = { canonical: string; }; +/** + * Converts a blog post title into a URL-safe kebab-case slug. + * + * Lowercases the title, strips all non-alphanumeric characters (except spaces), + * replaces spaces with hyphens, and collapses consecutive hyphens into one. + * + * @param title - The raw post title string. + * @returns The generated slug string. + */ +export function getSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-zA-Z0-9 ]/g, '') + .replace(/ /g, '-') + .replace(/-+/g, '-'); +} + /** * Generates a complete Markdown front matter block for a blog post. * * Constructs a YAML front matter section using the blog post details provided by the user, * including title, current date, type, canonical URL, and comma-separated tags. The front matter - * also embeds fixed cover image and author metadata along with a Markdown template containing guidelines - * for composing the blog content. + * also embeds fixed cover image and author metadata along with a Markdown template containing + * guidelines for composing the blog content. * - * @param answers - User inputs for the blog post, including title, excerpt, comma-separated tags, type, and canonical URL. + * @param answers - User inputs for the blog post, including title, excerpt, tags, type, and canonical URL. * @returns The generated Markdown front matter and blog post content template. */ -function genFrontMatter(answers: ComposePromptType): string { +export function genFrontMatter(answers: ComposePromptType): string { const tagArray = answers.tags.split(','); tagArray.forEach((tag: string, index: number) => { @@ -39,6 +58,7 @@ function genFrontMatter(answers: ComposePromptType): string { }); const tags = `'${tagArray.join("','")}'`; + /* eslint-disable max-len */ let frontMatter = dedent`--- title: ${answers.title ? answers.title : 'Untitled'} date: ${dayjs().format('YYYY-MM-DDTh:mm:ssZ')} @@ -112,64 +132,94 @@ function genFrontMatter(answers: ComposePromptType): string {
`; + /* eslint-enable max-len */ frontMatter += '\n---'; return frontMatter; } -inquirer - .prompt([ - { - name: 'title', - message: 'Enter post title:', - type: 'input' - }, - { - name: 'excerpt', - message: 'Enter post excerpt:', - type: 'input' - }, - { - name: 'tags', - message: 'Any Tags? Separate them with , or leave empty if no tags.', - type: 'input' - }, - { - name: 'type', - message: 'Enter the post type:', - type: 'list', - choices: ['Communication', 'Community', 'Engineering', 'Marketing', 'Strategy', 'Video'] - }, - { - name: 'canonical', - message: 'Enter the canonical URL if any:', - type: 'input' - } - ]) - .then((answers: ComposePromptType) => { - // Remove special characters and replace space with - - const fileName = answers.title - .toLowerCase() - .replace(/[^a-zA-Z0-9 ]/g, '') - .replace(/ /g, '-') - .replace(/-+/g, '-'); - const frontMatter = genFrontMatter(answers); - const filePath = `pages/blog/${fileName || 'untitled'}.md`; +/** + * Writes the blog post front matter to a file at the computed path. + * + * Resolves the file path from the slug generated from `answers.title` and writes + * the generated front matter content. Logs success on completion and throws on failure. + * + * @param answers - User inputs used to compute the slug and generate front matter content. + * @returns A Promise that resolves to the file path that was written. + */ +export function writePost(answers: ComposePromptType): Promise { + const slug = getSlug(answers.title); + const filePath = `pages/blog/${slug || 'untitled'}.md`; + const frontMatter = genFrontMatter(answers); + return new Promise((resolve, reject) => { fs.writeFile(filePath, frontMatter, { flag: 'wx' }, (err) => { if (err) { - throw err; + logger.error(err); + reject(err); } else { logger.info(`Blog post generated successfully at ${filePath}`); + resolve(filePath); } }); - }) - .catch((error) => { + }); +} + +/** + * Runs the interactive CLI prompt and writes the new blog post file. + * + * @returns A Promise that resolves when the blog post is written. + */ +/* istanbul ignore next */ +// eslint-disable-next-line require-jsdoc +async function main(): Promise { + await inquirer + .prompt([ + { + name: 'title', + message: 'Enter post title:', + type: 'input' + }, + { + name: 'excerpt', + message: 'Enter post excerpt:', + type: 'input' + }, + { + name: 'tags', + message: 'Any Tags? Separate them with , or leave empty if no tags.', + type: 'input' + }, + { + name: 'type', + message: 'Enter the post type:', + type: 'list', + choices: ['Communication', 'Community', 'Engineering', 'Marketing', 'Strategy', 'Video'] + }, + { + name: 'canonical', + message: 'Enter the canonical URL if any:', + type: 'input' + } + ]) + .then((answers: ComposePromptType) => { + return writePost(answers); + }) + .catch((error) => { + logger.error(error); + if (error.isTtyError) { + logger.error("Prompt couldn't be rendered in the current environment"); + } else { + logger.error('Something went wrong, sorry!'); + } + }); +} + +/* istanbul ignore next */ +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + // NOSONAR - top-level await is unsupported in Jest's CJS transform logger.error(error); - if (error.isTtyError) { - logger.error("Prompt couldn't be rendered in the current environment"); - } else { - logger.error('Something went wrong, sorry!'); - } }); +} diff --git a/tests/scripts/compose.test.ts b/tests/scripts/compose.test.ts new file mode 100644 index 000000000000..126cebf09555 --- /dev/null +++ b/tests/scripts/compose.test.ts @@ -0,0 +1,233 @@ +import dayjs from 'dayjs'; +import fs from 'fs'; + +import { genFrontMatter, getSlug, writePost } from '../../scripts/compose'; +import { logger } from '../../scripts/helpers/logger'; + +jest.mock('fs'); +jest.mock('dayjs'); +jest.mock('../../scripts/helpers/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn() + } +})); + +const FIXED_DATE = '2024-01-15T3:30:00+05:30'; +const mockDayjsInstance = { + format: jest.fn(() => FIXED_DATE) +}; + +const mockWriteFile = fs.writeFile as unknown as jest.Mock; +const mockLogger = jest.mocked(logger); + +(dayjs as unknown as jest.Mock).mockReturnValue(mockDayjsInstance); + +/** + * Configures mockWriteFile to call its callback with no error (success path). + */ +function mockWriteFileSuccess() { + mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: null) => void) => { + cb(null); + }); +} + +/** + * Configures mockWriteFile to call its callback with the given error (failure path). + * + * @param err - The error to pass to the writeFile callback. + */ +function mockWriteFileError(err: Error) { + mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: Error) => void) => { + cb(err); + }); +} + +/** + * Builds a minimal valid ComposePromptType answers object for use in tests. + * + * @param overrides - Optional field overrides to customise the returned object. + * @returns A complete answers object suitable for passing to genFrontMatter or writePost. + */ +function makeAnswers(overrides: Partial[0]> = {}) { + return { + title: 'My First Post', + excerpt: 'A short excerpt', + tags: 'asyncapi, community', + type: 'Engineering', + canonical: 'https://example.com/post', + ...overrides + }; +} + +describe('genFrontMatter', () => { + it('includes the correct title in the front matter', () => { + const result = genFrontMatter(makeAnswers({ title: 'Hello AsyncAPI' })); + + expect(result).toContain('title: Hello AsyncAPI'); + }); + + it('falls back to "Untitled" when title is empty', () => { + const result = genFrontMatter(makeAnswers({ title: '' })); + + expect(result).toContain('title: Untitled'); + }); + + it('includes the correct post type', () => { + const result = genFrontMatter(makeAnswers({ type: 'Community' })); + + expect(result).toContain('type: Community'); + }); + + it('includes the canonical URL when provided', () => { + const result = genFrontMatter(makeAnswers({ canonical: 'https://example.com' })); + + expect(result).toContain('canonical: https://example.com'); + }); + + it('uses an empty string for canonical when not provided', () => { + const result = genFrontMatter(makeAnswers({ canonical: '' })); + + expect(result).toContain('canonical: '); + }); + + it('parses multiple comma-separated tags correctly', () => { + const result = genFrontMatter(makeAnswers({ tags: 'asyncapi, community, testing' })); + + expect(result).toContain("tags: ['asyncapi','community','testing']"); + }); + + it('handles a single tag without trailing comma', () => { + const result = genFrontMatter(makeAnswers({ tags: 'asyncapi' })); + + expect(result).toContain("tags: ['asyncapi']"); + }); + + it('produces an empty tags array when tags field is empty', () => { + const result = genFrontMatter(makeAnswers({ tags: '' })); + + expect(result).toContain('tags: []'); + }); + + it('includes the excerpt in the front matter', () => { + const result = genFrontMatter(makeAnswers({ excerpt: 'This is my excerpt.' })); + + expect(result).toContain('excerpt: This is my excerpt.'); + }); + + it('uses a single space for excerpt when not provided', () => { + const result = genFrontMatter(makeAnswers({ excerpt: '' })); + + expect(result).toContain('excerpt: '); + }); + + it('includes the fixed date returned by dayjs', () => { + const result = genFrontMatter(makeAnswers()); + + expect(result).toContain(`date: ${FIXED_DATE}`); + }); + + it('closes with --- at the end', () => { + const result = genFrontMatter(makeAnswers()); + + expect(result.trim().endsWith('---')).toBe(true); + }); +}); + +describe('getSlug', () => { + it('converts a simple title to kebab-case', () => { + expect(getSlug('Hello World')).toBe('hello-world'); + }); + + it('lowercases the entire title', () => { + expect(getSlug('My GREAT Post')).toBe('my-great-post'); + }); + + it('strips special characters', () => { + expect(getSlug('Hello, World! #1')).toBe('hello-world-1'); + }); + + it('collapses multiple consecutive spaces/dashes into one dash', () => { + expect(getSlug('Hello World')).toBe('hello-world'); + }); + + it('handles a title with only special characters', () => { + expect(getSlug('!!!')).toBe(''); + }); + + it('returns an empty string for an empty title', () => { + expect(getSlug('')).toBe(''); + }); + + it('handles unicode letters that are not a-z as special characters (stripped)', () => { + expect(getSlug('café au lait')).toBe('caf-au-lait'); + }); + + it('handles a title with numbers', () => { + expect(getSlug('AsyncAPI 3.0 Released')).toBe('asyncapi-30-released'); + }); +}); + +describe('writePost', () => { + beforeEach(() => { + jest.clearAllMocks(); + (dayjs as unknown as jest.Mock).mockReturnValue(mockDayjsInstance); + }); + + it('calls fs.writeFile with the correct file path derived from the title', async () => { + mockWriteFileSuccess(); + + const answers = makeAnswers({ title: 'My Test Post' }); + const filePath = await writePost(answers); + + expect(filePath).toBe('pages/blog/my-test-post.md'); + expect(mockWriteFile).toHaveBeenCalledWith( + 'pages/blog/my-test-post.md', + expect.any(String), + { flag: 'wx' }, + expect.any(Function) + ); + }); + + it('uses "untitled" as the filename when the title is empty', async () => { + mockWriteFileSuccess(); + + const answers = makeAnswers({ title: '' }); + const filePath = await writePost(answers); + + expect(filePath).toBe('pages/blog/untitled.md'); + }); + + it('writes the generated front matter as the file content', async () => { + let writtenContent = ''; + + mockWriteFile.mockImplementation((_path: string, data: string, _opts: object, cb: (err: null) => void) => { + writtenContent = data; + cb(null); + }); + + const answers = makeAnswers({ title: 'Content Check' }); + + await writePost(answers); + + expect(writtenContent).toContain('title: Content Check'); + expect(writtenContent).toContain('type: Engineering'); + }); + + it('logs success after a successful file write', async () => { + mockWriteFileSuccess(); + + await writePost(makeAnswers({ title: 'Success Post' })); + + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('pages/blog/success-post.md')); + }); + + it('rejects and logs the error when fs.writeFile fails', async () => { + const writeError = new Error('EEXIST: file already exists'); + + mockWriteFileError(writeError); + + await expect(writePost(makeAnswers({ title: 'Duplicate Post' }))).rejects.toThrow('EEXIST: file already exists'); + expect(mockLogger.error).toHaveBeenCalledWith(writeError); + }); +});