From 97bcf4e5556b6858fa679df9a81b44b0eb002fc4 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:02:05 +0530 Subject: [PATCH 1/6] refactor: modularize compose script functions and add unit test coverage --- jest.config.js | 2 +- scripts/compose.ts | 136 +++++++++++++-------- tests/scripts/compose.test.ts | 219 ++++++++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+), 49 deletions(-) create mode 100644 tests/scripts/compose.test.ts 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..2a4666ce904a 100644 --- a/scripts/compose.ts +++ b/scripts/compose.ts @@ -6,6 +6,7 @@ import dedent from 'dedent'; import fs from 'fs'; import inquirer from 'inquirer'; import dayjs from 'dayjs'; +import { fileURLToPath } from 'url'; import { logger } from './helpers/logger'; @@ -20,6 +21,23 @@ 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 + * @returns + */ +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. * @@ -31,7 +49,7 @@ type ComposePromptType = { * @param answers - User inputs for the blog post, including title, excerpt, comma-separated 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) => { @@ -118,58 +136,80 @@ function genFrontMatter(answers: ComposePromptType): string { 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 + * @returns + */ +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) => { - logger.error(error); - if (error.isTtyError) { - logger.error("Prompt couldn't be rendered in the current environment"); - } else { - logger.error('Something went wrong, sorry!'); - } }); +} + +/** + * Runs the interactive CLI prompt and writes the new blog post file. + */ +async function main() { + 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) => { + 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(); +} diff --git a/tests/scripts/compose.test.ts b/tests/scripts/compose.test.ts new file mode 100644 index 000000000000..0c6831f05a95 --- /dev/null +++ b/tests/scripts/compose.test.ts @@ -0,0 +1,219 @@ +import fs from 'fs'; +import dayjs from 'dayjs'; + +import { genFrontMatter, getSlug, writePost } from '../../scripts/compose'; + +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'; + +// Provide a chainable dayjs mock that returns the fixed date on .format() +const mockDayjsInstance = { + format: jest.fn(() => FIXED_DATE) +}; + +(dayjs as unknown as jest.Mock).mockReturnValue(mockDayjsInstance); + +// Re-import logger after mocking so we can assert on it +import { logger } from '../../scripts/helpers/logger'; + +const mockWriteFile = fs.writeFile as unknown as jest.Mock; + +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)', () => { + // Non-ASCII letters like accented chars are stripped because the regex is [^a-zA-Z0-9 ] + 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 () => { + mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: null) => void) => { + cb(null); + }); + + 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 () => { + mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: null) => void) => { + cb(null); + }); + + 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 () => { + mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: null) => void) => { + cb(null); + }); + + await writePost(makeAnswers({ title: 'Success Post' })); + + expect(logger.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'); + + mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: Error) => void) => { + cb(writeError); + }); + + await expect(writePost(makeAnswers({ title: 'Duplicate Post' }))).rejects.toThrow('EEXIST: file already exists'); + expect(logger.error).toHaveBeenCalledWith(writeError); + }); +}); From b8d7b18bed38649d5673c79a198616a8e20a8efb Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:19:28 +0530 Subject: [PATCH 2/6] refactor: improve script and clean up test utility mocks --- scripts/compose.ts | 18 ++++++----- tests/scripts/compose.test.ts | 56 ++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/scripts/compose.ts b/scripts/compose.ts index 2a4666ce904a..b1db5f5bd659 100644 --- a/scripts/compose.ts +++ b/scripts/compose.ts @@ -2,10 +2,10 @@ * Script based on https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/scripts/compose.js */ +import dayjs from 'dayjs'; import dedent from 'dedent'; import fs from 'fs'; import inquirer from 'inquirer'; -import dayjs from 'dayjs'; import { fileURLToPath } from 'url'; import { logger } from './helpers/logger'; @@ -27,8 +27,8 @@ type ComposePromptType = { * Lowercases the title, strips all non-alphanumeric characters (except spaces), * replaces spaces with hyphens, and collapses consecutive hyphens into one. * - * @param title - * @returns + * @param title - The raw post title string. + * @returns The generated slug string. */ export function getSlug(title: string): string { return title @@ -43,10 +43,10 @@ export function getSlug(title: string): string { * * 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. */ export function genFrontMatter(answers: ComposePromptType): string { @@ -57,6 +57,7 @@ export 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')} @@ -130,6 +131,7 @@ export function genFrontMatter(answers: ComposePromptType): string {
`; + /* eslint-enable max-len */ frontMatter += '\n---'; @@ -142,8 +144,8 @@ export function genFrontMatter(answers: ComposePromptType): string { * 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 - * @returns + * @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); diff --git a/tests/scripts/compose.test.ts b/tests/scripts/compose.test.ts index 0c6831f05a95..126cebf09555 100644 --- a/tests/scripts/compose.test.ts +++ b/tests/scripts/compose.test.ts @@ -1,7 +1,8 @@ -import fs from 'fs'; 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'); @@ -13,19 +14,41 @@ jest.mock('../../scripts/helpers/logger', () => ({ })); const FIXED_DATE = '2024-01-15T3:30:00+05:30'; - -// Provide a chainable dayjs mock that returns the fixed date on .format() 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); -// Re-import logger after mocking so we can assert on it -import { logger } from '../../scripts/helpers/logger'; +/** + * 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); + }); +} -const mockWriteFile = fs.writeFile as unknown as jest.Mock; +/** + * 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', @@ -137,7 +160,6 @@ describe('getSlug', () => { }); it('handles unicode letters that are not a-z as special characters (stripped)', () => { - // Non-ASCII letters like accented chars are stripped because the regex is [^a-zA-Z0-9 ] expect(getSlug('café au lait')).toBe('caf-au-lait'); }); @@ -153,9 +175,7 @@ describe('writePost', () => { }); it('calls fs.writeFile with the correct file path derived from the title', async () => { - mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: null) => void) => { - cb(null); - }); + mockWriteFileSuccess(); const answers = makeAnswers({ title: 'My Test Post' }); const filePath = await writePost(answers); @@ -170,9 +190,7 @@ describe('writePost', () => { }); it('uses "untitled" as the filename when the title is empty', async () => { - mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: null) => void) => { - cb(null); - }); + mockWriteFileSuccess(); const answers = makeAnswers({ title: '' }); const filePath = await writePost(answers); @@ -197,23 +215,19 @@ describe('writePost', () => { }); it('logs success after a successful file write', async () => { - mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: null) => void) => { - cb(null); - }); + mockWriteFileSuccess(); await writePost(makeAnswers({ title: 'Success Post' })); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('pages/blog/success-post.md')); + 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'); - mockWriteFile.mockImplementation((_path: string, _data: string, _opts: object, cb: (err: Error) => void) => { - cb(writeError); - }); + mockWriteFileError(writeError); await expect(writePost(makeAnswers({ title: 'Duplicate Post' }))).rejects.toThrow('EEXIST: file already exists'); - expect(logger.error).toHaveBeenCalledWith(writeError); + expect(mockLogger.error).toHaveBeenCalledWith(writeError); }); }); From 29ad5ed64bd8390b28f8c5f06c59530b7625c108 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:42:55 +0530 Subject: [PATCH 3/6] chore: add istanbul ignore next to main function in compose script --- scripts/compose.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/compose.ts b/scripts/compose.ts index b1db5f5bd659..6853434868ed 100644 --- a/scripts/compose.ts +++ b/scripts/compose.ts @@ -168,6 +168,7 @@ export function writePost(answers: ComposePromptType): Promise { /** * Runs the interactive CLI prompt and writes the new blog post file. */ +/* istanbul ignore next */ async function main() { inquirer .prompt([ From f36e4874e1c3e1c37853bd58cc31b32e0bdff529 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:53:07 +0530 Subject: [PATCH 4/6] fix: return writePost --- scripts/compose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/compose.ts b/scripts/compose.ts index 6853434868ed..f08ddab43ff4 100644 --- a/scripts/compose.ts +++ b/scripts/compose.ts @@ -200,7 +200,7 @@ async function main() { } ]) .then((answers: ComposePromptType) => { - writePost(answers); + return writePost(answers); }) .catch((error) => { logger.error(error); From 937acfa92aded53061ba7879fb1da2d301144f01 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:31:28 +0530 Subject: [PATCH 5/6] refactor: update import path for node:url, add type annotations to main, and improve error handling for CLI execution --- scripts/compose.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/scripts/compose.ts b/scripts/compose.ts index f08ddab43ff4..91b7b0d8b69e 100644 --- a/scripts/compose.ts +++ b/scripts/compose.ts @@ -2,11 +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 { fileURLToPath } from 'url'; import { logger } from './helpers/logger'; @@ -167,10 +168,12 @@ export function writePost(answers: ComposePromptType): Promise { /** * 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 */ -/* istanbul ignore next */ -async function main() { - inquirer +async function main(): Promise { + await inquirer .prompt([ { name: 'title', @@ -214,5 +217,7 @@ async function main() { /* istanbul ignore next */ if (process.argv[1] === fileURLToPath(import.meta.url)) { - main(); + main().catch((error) => { + logger.error(error); + }); } From a0af92a6c899e2ac136ca38bfbb0cf615bd3ae90 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:42:55 +0530 Subject: [PATCH 6/6] refactor: update istanbul ignore syntax and add eslint/sonar suppressions in compose script --- scripts/compose.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/compose.ts b/scripts/compose.ts index 91b7b0d8b69e..36159570cee3 100644 --- a/scripts/compose.ts +++ b/scripts/compose.ts @@ -170,8 +170,9 @@ export function writePost(answers: ComposePromptType): Promise { * 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 */ +/* istanbul ignore next */ +// eslint-disable-next-line require-jsdoc async function main(): Promise { await inquirer .prompt([ @@ -218,6 +219,7 @@ async function main(): Promise { /* 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); }); }