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);
+ });
+});