Skip to content

390-feat: Add open graph previews #894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 50 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
e4f5ab0
feat: 390 - add images for openGraph generate function
LaraNU May 11, 2025
3e209ec
feat: 390 - implement OG image generation script for all pages
LaraNU May 11, 2025
ce91a92
docs: 390 - add generated OG image folders to .gitignore to prevent c…
LaraNU May 11, 2025
9fe67b0
fix: 390 - add missing image asset for OG generation
LaraNU May 11, 2025
8aab59b
refactor: 390 - convert title to lowercase in generateOgImagePage for…
LaraNU May 12, 2025
0cd0f35
refactor: 390 - improve code structure by splitting generate-og-image…
LaraNU May 14, 2025
f707a8d
feat: 390 - create reusable metadata generator function
LaraNU May 14, 2025
1ec5b1b
feat: 390 - add metadataBase for absolute URL generation for OG URLs
LaraNU May 14, 2025
b8fa55d
feat: 390 - add dynamic metadata to all pages using generatePageMetadata
LaraNU May 14, 2025
ab05327
fix: 390 - validate API_URL before course generation
LaraNU May 14, 2025
fe9c639
chore: 390 - resolve merge conflicts
LaraNU May 15, 2025
704eaac
refactor: 390 - change data fetching approach
LaraNU May 15, 2025
181b67c
refactor: 390 - replace if-else with ternary for date assignment
LaraNU May 15, 2025
5832416
refactor: 390 - move and modularize generate-courses-tree logic
YulikK May 16, 2025
bb487c5
refactor: 390 - move and modularize generate-pages-tree logic
LaraNU May 16, 2025
f93c1ff
chore: 390 - resolve merge conflicts
LaraNU May 16, 2025
d681867
refactor: 390 - move UI components to view folder
LaraNU May 16, 2025
eafbb19
feat: 390 - add PNG image conversion using sharp
LaraNU May 16, 2025
687abec
refactor: 390 - enhance schedule data with name and formatted dates
LaraNU May 16, 2025
43f6443
feat: 390 - implement logo fetching with normalized names
LaraNU May 16, 2025
b701d4c
feat: 390 - add combined data fetching service
LaraNU May 16, 2025
359077a
refactor: 390 - udate OG generate function
LaraNU May 16, 2025
fb908d4
refactor: 390 - relocate open-graph.data.ts to scripts
LaraNU May 16, 2025
67af13b
refactor: 390 - remove unused images
LaraNU May 16, 2025
f6af7df
fix: 390 - fix import order in generate-og-script.ts
LaraNU May 16, 2025
1139574
refactor: 390 - use another link for logo
LaraNU May 17, 2025
c3d0d5d
refactor: 390 - remove link
LaraNU May 17, 2025
9c7d0ec
refactor: 390 - add error handling for file operations
LaraNU May 17, 2025
e7f9bd0
fix: 390 - fix incorrect Buffer generic type usage
LaraNU May 17, 2025
f395a12
refactor: 390 - improve execution flow and error handling
LaraNU May 17, 2025
09d81ab
refactor: 390 - inconsistent function naming
LaraNU May 17, 2025
dcacd53
fix: 390 - fix quotation mark syntax in description
LaraNU May 17, 2025
b129fd5
refactor: 390 - consider adding error handling to the image loading f…
LaraNU May 17, 2025
cad36cb
feat: 390 - consider adding image optimization options
LaraNU May 17, 2025
9ec1aa1
fix: 390 - fix the date formatting fallback logic
LaraNU May 17, 2025
dbd488c
refactor: 390 - add fixed width
LaraNU May 17, 2025
a60d6dd
refactor: 390 - change error message
LaraNU May 17, 2025
e4bb4a2
feat: 390 - add function for generate page metadata
LaraNU May 17, 2025
0a17097
refactor: 390 - update page descriptions
LaraNU May 17, 2025
294cd56
feat: 390 - add standard SEO fields to course pages and main page
LaraNU May 17, 2025
7235dd5
feat: 390 - add standard SEO fields to all pages
LaraNU May 17, 2025
d719bbe
refactor: 390 - rename image paths according to new convention
LaraNU May 17, 2025
6f9fc9e
refactor: 390 - reorganize and consolidate course-related types
YulikK May 17, 2025
5e24bee
refactor: 390 - consolidate OG image paths and constants
YulikK May 17, 2025
56b0857
fix: 390 - restore ru suffix in javascript-preschool image filename
LaraNU May 18, 2025
aa58f2e
chore: 390 - resolve merge conflicts
LaraNU May 18, 2025
d42d1f0
refactor: 390 - reorganize and consolidate page-related types
LaraNU May 18, 2025
a0e48b1
refactor: 390 - change file extension from .tsx to .ts
LaraNU May 18, 2025
76ee539
fix: 390 - eslint errors
LaraNU May 18, 2025
b775830
refactor: 390 - move API-related functions to api directory
LaraNU May 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ src/shared/__tests__/test-results
src/shared/__tests__/report
/blob-report/
/playwright/.cache/
/public/og-images
/public/og-images-pages

# env
.env
27 changes: 27 additions & 0 deletions dev-data/open-graph.data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export const COURSE_SLUGS = {
JS_PRESCHOOL_RU: 'javascript-preschool-ru',
JS_EN: 'javascript',
JS_RU: 'javascript-ru',
REACT: 'reactjs',
ANGULAR: 'angular',
NODE: 'nodejs',
AWS_FUNDAMENTALS: 'aws-fundamentals',
AWS_CLOUD_DEVELOPER: 'aws-cloud-developer',
AWS_DEVOPS: 'aws-devops',
} as const;

export const RS_PAGES = {
MAIN: 'Home',
MENTORSHIP: 'Mentorship',
COMMUNITY: 'Community',
DOCS: 'Docs',
COURSES: 'Courses',
} as const;

export const Descriptions = {
MAIN: 'Connecting people, growing together, having fun',
MENTORSHIP: 'By teaching others, you learn yourself',
COMMUNITY: 'An international community of developers since 2013',
DOCS: 'School docs hub: rules, guides, and FAQs',
COURSES: 'Community driven. 100% free of charge. Journey to full stack mastery',
} as const;
36 changes: 36 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "tsc && next build",
"generate-og": "tsx scripts/generate-og-script.ts",
"dev": "npm run generate-og && next dev --turbopack",
"build": "npm run generate-og && tsc && next build",
"stylelint": "npx stylelint \"**/*.scss\"",
"stylelint:fix": "npx stylelint \"**/*.scss\" --fix",
"lint": "npx eslint . --report-unused-disable-directives",
Expand Down Expand Up @@ -91,6 +92,7 @@
"stylelint-config-standard-scss": "^14.0.0",
"stylelint-order": "^7.0.0",
"stylelint-prettier": "^5.0.3",
"tsx": "^4.19.4",
"typescript": "^5.8.2",
"typescript-eslint": "^8.31.1",
"vitest": "^3.0.5"
Expand Down
Binary file added public/fonts/Inter_28pt-Bold.ttf
Binary file not shown.
Binary file added public/fonts/Inter_28pt-Regular.ttf
Binary file not shown.
120 changes: 120 additions & 0 deletions scripts/generate-og-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env tsx

import { JSX } from 'react';
import 'dotenv/config';
import fs from 'fs/promises';
import path from 'path';

import { ApiCourse } from './types';
import { getCourseInfo } from './utils/course-info';
import { ensureDirExists } from './utils/ensure-dir-exists';
import { fetchCoursesList } from './utils/fetch-courses-list';
import { createCourseTree } from './utils/generate-courses-tree';
import { generateImage } from './utils/generate-image';
import { createPageTree } from './utils/generate-page-tree';
import { type Font, loadFont } from './utils/load-fonts';
import { loadImageAsDataUri } from './utils/load-image-as-data-uri';
import { COURSE_TITLES } from '../dev-data/course-titles.data';
import { COURSE_SLUGS, Descriptions, RS_PAGES } from '../dev-data/open-graph.data';

const fontRegularPromise: Promise<Font> = loadFont(400);
const fontBoldPromise: Promise<Font> = loadFont(700);

type CourseKey = keyof typeof COURSE_SLUGS;

const COURSES_JSON_URL = process.env.API_URL;

async function generateOGCourses(): Promise<void> {
const coursesList: ApiCourse[] = await fetchCoursesList(COURSES_JSON_URL);
const ogDir: string = path.join(process.cwd(), 'public', 'og-images');

await ensureDirExists(ogDir);

const rsLogoDataUriPromise: Promise<string> = loadImageAsDataUri('src/shared/assets/og-logos/rs-school.png', 'image/png');

const [fontRegular, fontBold, rsLogoDataUri] =
await Promise.all([
fontRegularPromise,
fontBoldPromise,
rsLogoDataUriPromise,
]);

const fonts: Font[] = [fontRegular, fontBold];

const leftTitle: string = 'RS School';
const leftSubtitle: string = 'Free courses. High motivation';

for (const key of Object.keys(COURSE_TITLES) as CourseKey[]) {
const slug: string = COURSE_SLUGS[key];
const title: string = COURSE_TITLES[key];

const formattedDate: string = await getCourseInfo(coursesList, slug);

const logoDataUri: string = await loadImageAsDataUri(`src/shared/assets/og-logos/${slug}.svg`, 'image/svg+xml');

const tree: JSX.Element = createCourseTree(
title,
leftTitle,
leftSubtitle,
formattedDate,
rsLogoDataUri,
logoDataUri,
);

const buffer: Buffer<ArrayBufferLike> | null = await generateImage(tree, fonts);

if (!buffer) {
throw new Error(`Doesn't generate image for "${slug}"`);
}

await fs.writeFile(path.join(ogDir, `${slug}.png`), buffer);
console.log(`${slug} created`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Add try-catch for image file operations

Missing error handling for image file operations could lead to unhandled exceptions.

    const buffer: Buffer<ArrayBufferLike> | null = await generateImage(tree, fonts);

    if (!buffer) {
      throw new Error(`Doesn't generate image for "${slug}"`);
    }

-   await fs.writeFile(path.join(ogDir, `${slug}.png`), buffer);
-   console.log(`${slug} created`);
+   try {
+     await fs.writeFile(path.join(ogDir, `${slug}.png`), buffer);
+     console.log(`${slug} created`);
+   } catch (error) {
+     console.error(`Error writing image file for ${slug}:`, error);
+     throw error;
+   }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const buffer: Buffer<ArrayBufferLike> | null = await generateImage(tree, fonts);
if (!buffer) {
throw new Error(`Doesn't generate image for "${slug}"`);
}
await fs.writeFile(path.join(ogDir, `${slug}.png`), buffer);
console.log(`${slug} created`);
const buffer: Buffer<ArrayBufferLike> | null = await generateImage(tree, fonts);
if (!buffer) {
throw new Error(`Doesn't generate image for "${slug}"`);
}
try {
await fs.writeFile(path.join(ogDir, `${slug}.png`), buffer);
console.log(`${slug} created`);
} catch (error) {
console.error(`Error writing image file for ${slug}:`, error);
throw error;
}
πŸ€– Prompt for AI Agents
In scripts/generate-og-script.ts around lines 71 to 78, the image generation and
file writing operations lack error handling, which can cause unhandled
exceptions. Wrap the code that generates the image buffer and writes the file in
a try-catch block. In the catch block, log or handle the error appropriately to
prevent the script from crashing unexpectedly.

}
}

async function generateOgImagePages(): Promise<void> {
const rsBannerPromise: Promise<string> = loadImageAsDataUri('src/shared/assets/og-logos/rs-banner.png', 'image/png');
const rsMascotsPromise: Promise<string> = loadImageAsDataUri('src/shared/assets/og-logos/mentor-with-his-students.png', 'image/png');
const mapBgPromise: Promise<string> = loadImageAsDataUri('src/shared/assets/og-logos/map.png', 'image/png');

const ogDir: string = path.join(process.cwd(), 'public', 'og-images-pages');

await ensureDirExists(ogDir);

const [fontRegular, fontBold, rsBannerUri, rsMascotsUri, mapBgUri] =
await Promise.all([
fontRegularPromise,
fontBoldPromise,
rsBannerPromise,
rsMascotsPromise,
mapBgPromise,
]);

const fonts: Font[] = [fontRegular, fontBold];

for (const key of Object.keys(RS_PAGES) as Array<keyof typeof RS_PAGES>) {
const title: string = RS_PAGES[key];
const description: string = Descriptions[key];

const tree: JSX.Element = createPageTree(title, description, rsBannerUri, rsMascotsUri, mapBgUri);
const buffer: Buffer<ArrayBufferLike> | null = await generateImage(tree, fonts);
const fileName: string = `${title.toLowerCase()}.png`.replace(/\s+/g, '-');

if (!buffer) {
throw new Error(`Doesn't generate image for "${title}"`);
}

await fs.writeFile(path.join(ogDir, fileName), buffer);
console.log(`${fileName} created`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion

Similar error handling needed in page image generation

Apply the same error handling pattern here as suggested for course image generation.

    const buffer: Buffer<ArrayBufferLike> | null = await generateImage(tree, fonts);
    const fileName: string = `${title.toLowerCase()}.png`.replace(/\s+/g, '-');

    if (!buffer) {
      throw new Error(`Doesn't generate image for "${title}"`);
    }

-   await fs.writeFile(path.join(ogDir, fileName), buffer);
-   console.log(`${fileName} created`);
+   try {
+     await fs.writeFile(path.join(ogDir, fileName), buffer);
+     console.log(`${fileName} created`);
+   } catch (error) {
+     console.error(`Error writing image file for ${fileName}:`, error);
+     throw error;
+   }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fileName: string = `${title.toLowerCase()}.png`.replace(/\s+/g, '-');
if (!buffer) {
throw new Error(`Doesn't generate image for "${title}"`);
}
await fs.writeFile(path.join(ogDir, fileName), buffer);
console.log(`${fileName} created`);
}
const buffer: Buffer<ArrayBufferLike> | null = await generateImage(tree, fonts);
const fileName: string = `${title.toLowerCase()}.png`.replace(/\s+/g, '-');
if (!buffer) {
throw new Error(`Doesn't generate image for "${title}"`);
}
try {
await fs.writeFile(path.join(ogDir, fileName), buffer);
console.log(`${fileName} created`);
} catch (error) {
console.error(`Error writing image file for ${fileName}:`, error);
throw error;
}
}
πŸ€– Prompt for AI Agents
In scripts/generate-og-script.ts around lines 122 to 130, the error handling for
the image generation is missing proper checks similar to those used in course
image generation. Add a check to verify if the buffer is valid before attempting
to write the file, and if not, throw an error with a descriptive message
including the title. This ensures consistent and clear error handling for image
generation failures.

}

generateOgImagePages().catch((err) => {
console.error(err);
process.exit(1);
});

generateOGCourses().catch((err) => {
console.error(err);
process.exit(1);
});
4 changes: 4 additions & 0 deletions scripts/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type ApiCourse = {
startDate: string;
descriptionUrl: string;
};
19 changes: 19 additions & 0 deletions scripts/utils/course-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import dayjs from 'dayjs';

import type { ApiCourse } from '../types';

export const getCourseInfo = (coursesList: ApiCourse[], slug: string): string => {
const courseInfo: ApiCourse | undefined = coursesList.find((c) => c.descriptionUrl.toLowerCase().endsWith(slug));

let rawDate: string;

if (!courseInfo) {
rawDate = '';
} else {
rawDate = courseInfo.startDate;
}

const formattedDate: string = rawDate ? dayjs(rawDate).format('MMM DD, YYYY') : 'TBD';

return formattedDate;
};
14 changes: 14 additions & 0 deletions scripts/utils/ensure-dir-exists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import fs from 'node:fs/promises';

export const ensureDirExists = async (dirPath: string): Promise<void> => {
const stat = await fs.stat(dirPath).catch((err) => {
if (err.code === 'ENOENT') {
return null;
}
throw err;
});

if (!stat || !stat.isDirectory()) {
await fs.mkdir(dirPath, { recursive: true });
}
};
10 changes: 10 additions & 0 deletions scripts/utils/fetch-courses-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ApiCourse } from '../types';

export async function fetchCoursesList(url: string): Promise<ApiCourse[]> {
const res = await fetch(url);

if (!res.ok) {
throw new Error(`API error ${res.status}`);
}
return (await res.json()) as ApiCourse[];
}
Loading
Loading