diff --git a/.gitignore b/.gitignore index ff0063bc8..4389c2ae8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ src/shared/__tests__/test-results src/shared/__tests__/report /blob-report/ /playwright/.cache/ +/public/og # env .env diff --git a/package-lock.json b/package-lock.json index e15db24b5..932770c37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,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" @@ -16313,6 +16314,41 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.19.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", + "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 703cf9b59..be0df3375 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" diff --git a/public/fonts/Inter_28pt-Bold.ttf b/public/fonts/Inter_28pt-Bold.ttf new file mode 100644 index 000000000..d17828b2b Binary files /dev/null and b/public/fonts/Inter_28pt-Bold.ttf differ diff --git a/public/fonts/Inter_28pt-Regular.ttf b/public/fonts/Inter_28pt-Regular.ttf new file mode 100644 index 000000000..855b6f476 Binary files /dev/null and b/public/fonts/Inter_28pt-Regular.ttf differ diff --git a/scripts/api/get-combain-data-courses.ts b/scripts/api/get-combain-data-courses.ts new file mode 100644 index 000000000..0c34fefb1 --- /dev/null +++ b/scripts/api/get-combain-data-courses.ts @@ -0,0 +1,45 @@ +import { getCoursesLogo } from './get-courses-logo'; +import { getCoursesSchedule } from './get-courses-schedule'; +import type { CourseData } from '../types/types'; +import { TO_BE_DETERMINED } from '@/shared/constants'; + +function normalizeUrl(url: string | null): string { + if (!url) { + return ''; + } + return url + .toLowerCase() + .replace(/(https?:\/\/)?(www\.)?/, '') + .replace(/\/$/, ''); +} + +export async function getCombinedDataCourses(): Promise { + try { + const [coursesWithLogos, coursesWithDates] = await Promise.all([ + getCoursesLogo(), + getCoursesSchedule(), + ]); + + return coursesWithLogos + .map((courseLogo) => { + const matchedCourse = coursesWithDates.find((courseDate) => { + const logoUrl = normalizeUrl(courseLogo.url); + const dateUrl = normalizeUrl(courseDate.descriptionUrl); + + return dateUrl && logoUrl && dateUrl === logoUrl; + }); + + return { + normalizeName: courseLogo.normalizeName, + name: courseLogo.name, + logo: courseLogo.logo, + startDate: matchedCourse?.startDate || TO_BE_DETERMINED, + url: courseLogo.url, + }; + }) + .filter((course) => course.logo.src); + } catch (error) { + console.error('Error fetching combined courses:', error); + return []; + } +} diff --git a/scripts/api/get-courses-logo.ts b/scripts/api/get-courses-logo.ts new file mode 100644 index 000000000..87b32dd90 --- /dev/null +++ b/scripts/api/get-courses-logo.ts @@ -0,0 +1,57 @@ +import { StaticImageData } from 'next/image'; + +import { CoursesResponse } from '../../src/entities/course/types'; +import { api } from '../../src/shared/api/api'; +import { findAssetImageById } from '../../src/shared/helpers/find-asset-image-by-id'; + +function normalizeName(name: string): string { + return name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +export async function getCoursesLogo(): Promise< + { + normalizeName: string; + name: string; + url: string; + logo: StaticImageData; + }[] +> { + try { + const response = await api.course.queryCourses(); + const courses = response.result as CoursesResponse; + const assets = courses.includes?.Asset; + + if (!assets || !courses.items) { + return []; + } + + return courses.items + .map((course) => { + const iconId = course.fields.icon?.sys?.id; + const logo = iconId + ? findAssetImageById(assets, iconId) + : { + src: '', + width: 0, + height: 0, + }; + + return { + normalizeName: normalizeName(course.fields.name), + name: course.fields.name, + url: course.fields.url, + logo, + }; + }) + .filter((course) => course.logo.src !== ''); + } catch (error) { + console.error('Error fetching courses logos:', error); + return []; + } +} diff --git a/scripts/api/get-courses-schedule.ts b/scripts/api/get-courses-schedule.ts new file mode 100644 index 000000000..26f77124e --- /dev/null +++ b/scripts/api/get-courses-schedule.ts @@ -0,0 +1,26 @@ +import dayjs from 'dayjs'; + +import { CoursesScheduleResponse } from '../../src/entities/course/types'; +import { api } from '../../src/shared/api/api'; + +export async function getCoursesSchedule(): Promise< + { + name: string; + startDate: string; + descriptionUrl: string; + }[] +> { + try { + const response = await api.course.queryCoursesSchedule(); + const courses = response.result as CoursesScheduleResponse; + + return courses.map((course) => ({ + name: course.name || '', + startDate: course.startDate ? dayjs(course.startDate).format('MMM DD, YYYY') : '', + descriptionUrl: course.descriptionUrl || '', + })); + } catch (error) { + console.error('Error fetching courses schedule:', error); + return []; + } +} diff --git a/scripts/generate-og-script.ts b/scripts/generate-og-script.ts new file mode 100644 index 000000000..ecc6ac86a --- /dev/null +++ b/scripts/generate-og-script.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env tsx + +import 'dotenv/config'; +import fs from 'fs/promises'; +import path from 'path'; + +import { getCombinedDataCourses } from './api/get-combain-data-courses'; +import { RS_PAGES } from './shared/constants'; +import { ensureDirExists } from './utils/ensure-dir-exists'; +import { generateImage } from './utils/generate-image'; +import { fonts } from './utils/load-fonts'; +import { createCourseTree } from './view/courses-tree/generate-courses-tree'; +import { createPageTree } from './view/pages-tree/generate-pages-tree'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; + +const ogDir: string = path.join(process.cwd(), 'public', OG_FOLDER); +const ogCoursesDir: string = path.join(ogDir, OG_COURSES_FOLDER); + +await ensureDirExists(ogDir); +await ensureDirExists(ogCoursesDir); + +async function generateOgCourses(): Promise { + const courseLogos = await getCombinedDataCourses(); + + for (const course of courseLogos) { + const tree = await createCourseTree(course); + + const buffer: Buffer | null = await generateImage(tree, fonts); + + if (!buffer) { + throw new Error(`Doesn't generate image for "${course.normalizeName}"`); + } + + await fs.writeFile(path.join(ogCoursesDir, `${course.normalizeName}.png`), buffer); + console.log(`${course.normalizeName} created`); + } +} + +async function generateOgImagePages(): Promise { + for (const pageKey of Object.keys(RS_PAGES) as Array) { + const page = RS_PAGES[pageKey]; + const tree = await createPageTree({ page }); + + if (!tree) { + console.error(`Skipping image for "${page.title}" due to failed tree creation.`); + continue; + } + + const buffer: Buffer | null = await generateImage(tree, fonts); + + if (!buffer) { + throw new Error(`Doesn't generate image for "${page.title}"`); + } + + const fileName: string = + `${page.title.toLowerCase()}` + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-') + '.png'; + + await fs.writeFile(path.join(ogDir, fileName), buffer); + console.log(`${fileName} created`); + } +} + +async function main() { + try { + await generateOgImagePages(); + await generateOgCourses(); + console.log('All Open Graph images generated successfully'); + } catch (err) { + console.error('Error generating Open Graph images:', err); + process.exit(1); + } +} + +main(); diff --git a/scripts/shared/constants.ts b/scripts/shared/constants.ts new file mode 100644 index 000000000..76fd0fa5e --- /dev/null +++ b/scripts/shared/constants.ts @@ -0,0 +1,22 @@ +export const RS_PAGES = { + MAIN: { + title: 'Home', + description: 'Free, community-driven IT education for future developers.', + }, + MENTORSHIP: { + title: 'Mentorship', + description: 'Mentor at RS School and help others grow.', + }, + COMMUNITY: { + title: 'Community', + description: 'Join the RS School developer community.', + }, + DOCS: { + title: 'Docs', + description: 'RS School Docs: rules, guides, FAQs.', + }, + COURSES: { + title: 'Courses', + description: 'Free RS School courses: JavaScript, React, Node.js, AWS, and more.', + }, +} as const; diff --git a/scripts/types/types.ts b/scripts/types/types.ts new file mode 100644 index 000000000..da9472097 --- /dev/null +++ b/scripts/types/types.ts @@ -0,0 +1,23 @@ +import { StaticImageData } from 'next/image'; + +export type CourseData = { + normalizeName: string; + name: string; + logo: StaticImageData; + startDate: string; + url: string; +}; + +export type PageData = { + page: { + title: string; + description: string; + }; +}; + +export type Font = { + name: string; + data: ArrayBuffer; + weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + style?: 'normal' | 'italic'; +}; diff --git a/scripts/utils/ensure-dir-exists.ts b/scripts/utils/ensure-dir-exists.ts new file mode 100644 index 000000000..a0f12676d --- /dev/null +++ b/scripts/utils/ensure-dir-exists.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs/promises'; + +export const ensureDirExists = async (dirPath: string): Promise => { + 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 }); + } +}; diff --git a/scripts/utils/generate-image.ts b/scripts/utils/generate-image.ts new file mode 100644 index 000000000..aae93df9e --- /dev/null +++ b/scripts/utils/generate-image.ts @@ -0,0 +1,32 @@ +import { JSX } from 'react'; +import { ImageResponse } from 'next/og'; + +import { Font } from '../types/types'; + +export async function generateImage( + tree: JSX.Element, + fonts: Font[], + width = 1200, + height = 630, +): Promise { + if (!tree) { + return null; + } + + if (!fonts || fonts.length === 0) { + return null; + } + + try { + const imageRes = new ImageResponse(tree, { + width, + height, + fonts, + }); + + return Buffer.from(await imageRes.arrayBuffer()); + } catch (e) { + console.error('Error generating image:', e); + return null; + } +} diff --git a/scripts/utils/load-fonts.ts b/scripts/utils/load-fonts.ts new file mode 100644 index 000000000..c73e5e86a --- /dev/null +++ b/scripts/utils/load-fonts.ts @@ -0,0 +1,34 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { Font } from '../types/types'; + +const loadFont = async (weight: 400 | 700): Promise => { + const fileName = weight === 700 ? 'Inter_28pt-Bold.ttf' : 'Inter_28pt-Regular.ttf'; + const fontPath = path.join(process.cwd(), 'public', 'fonts', fileName); + + try { + const buffer = await fs.readFile(fontPath); + const arrayBuffer = new Uint8Array(buffer).buffer; + + return { + name: 'Inter', + data: arrayBuffer, + weight, + style: 'normal', + }; + } catch (error) { + console.error(`Error loading font (${fileName}):`, error); + throw new Error(`Failed to load font: ${fileName}`); + } +}; + +const fontRegularPromise: Promise = loadFont(400); +const fontBoldPromise: Promise = loadFont(700); + +const [fontRegular, fontBold] = await Promise.all([ + fontRegularPromise, + fontBoldPromise, +]); + +export const fonts: Font[] = [fontRegular, fontBold]; diff --git a/scripts/utils/load-image-as-data-uri.ts b/scripts/utils/load-image-as-data-uri.ts new file mode 100644 index 000000000..cee424487 --- /dev/null +++ b/scripts/utils/load-image-as-data-uri.ts @@ -0,0 +1,21 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; + +export const loadImageAsDataUri = async (relativePath: string): Promise => { + try { + const buf: Buffer = await fs.readFile(path.join(process.cwd(), relativePath)); + + const pngBuffer = await sharp(buf) + .toFormat('png', { + compressionLevel: 9, + quality: 90, + }) + .toBuffer(); + + return `data:image/png;base64,${pngBuffer.toString('base64')}`; + } catch (error) { + console.error(`Error loading image from ${relativePath}:`, error); + throw new Error(`Failed to load image as data URI: ${error}`); + } +}; diff --git a/scripts/view/courses-tree/generate-courses-tree.styles.ts b/scripts/view/courses-tree/generate-courses-tree.styles.ts new file mode 100644 index 000000000..70ecb250b --- /dev/null +++ b/scripts/view/courses-tree/generate-courses-tree.styles.ts @@ -0,0 +1,69 @@ +export const stylesCourseTree = { + container: { + display: 'flex', + width: 1200, + height: 630, + fontFamily: 'Inter', + }, + leftSection: { + flex: 3, + background: '#000', + color: '#f0f2f5', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: 40, + boxSizing: 'border-box', + gap: 25, + }, + rightSection: { + flex: 2, + background: '#ffda1f', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 25, + padding: 20, + boxSizing: 'border-box', + }, + logo: { + width: 250, + height: 250, + }, + title: { + fontSize: 72, + fontWeight: 700, + fontFamily: 'Inter', + margin: 0, + }, + subtitle: { + fontSize: 36, + fontFamily: 'Inter', + fontWeight: 400, + margin: 0, + }, + courseLogo: { + width: 250, + height: 250, + objectFit: 'contain', + }, + courseTitle: { + fontSize: 50, + color: '#000', + margin: 0, + fontFamily: 'Inter', + fontWeight: 400, + textAlign: 'center', + }, + startDate: { + fontSize: 38, + color: '#000', + margin: 0, + fontFamily: 'Inter', + fontWeight: 700, + fontStyle: 'italic', + textTransform: 'uppercase', + }, +} as const; diff --git a/scripts/view/courses-tree/generate-courses-tree.tsx b/scripts/view/courses-tree/generate-courses-tree.tsx new file mode 100644 index 000000000..46b7cec83 --- /dev/null +++ b/scripts/view/courses-tree/generate-courses-tree.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { stylesCourseTree } from './generate-courses-tree.styles'; +import { CourseData } from '../../types/types'; +import { loadImageAsDataUri } from '../../utils/load-image-as-data-uri'; + +const rsStudentPromise = loadImageAsDataUri('src/shared/assets/rs-school.webp'); + +export async function createCourseTree( + course: CourseData, +): Promise { + const { name, logo, startDate } = course; + const rsStudentImg = await rsStudentPromise; + + return ( +
+
+ Sloth mascot works on the laptop +

RS School

+

Free courses. High motivation

+
+
+ {`${name} +

{`${name} Course`}

+

{`Start: ${startDate}`}

+
+
+ ); +} diff --git a/scripts/view/pages-tree/generate-pages-tree.styles.ts b/scripts/view/pages-tree/generate-pages-tree.styles.ts new file mode 100644 index 000000000..28937cd2f --- /dev/null +++ b/scripts/view/pages-tree/generate-pages-tree.styles.ts @@ -0,0 +1,75 @@ +export const stylesPageTree = { + container: { + width: 1200, + height: 630, + display: 'flex', + flexDirection: 'column', + gap: 20, + alignItems: 'flex-start', + padding: '100px 80px', + margin: 0, + backgroundColor: '#fff', + fontFamily: 'Inter', + color: '#000', + boxSizing: 'border-box', + position: 'relative', + }, + mapBg: { + position: 'absolute', + top: 0, + right: 120, + objectFit: 'contain', + height: 630, + }, + content: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + gap: 30, + margin: 0, + }, + title: { + fontFamily: 'Inter', + fontSize: 82, + fontWeight: 700, + margin: 0, + }, + description: { + fontFamily: 'Inter', + fontSize: 34, + fontWeight: 400, + color: '#000', + width: '700px', + margin: 0, + }, + mascotsContainer: { + width: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginTop: 0, + position: 'absolute', + bottom: 45, + left: 50, + }, + mascots: { + objectFit: 'contain', + width: 430, + }, + yellowBar: { + position: 'absolute', + bottom: 0, + left: 0, + width: '1200px', + height: '36px', + backgroundColor: '#ffda1f', + }, + banner: { + position: 'absolute', + top: 60, + right: 70, + objectFit: 'contain', + width: 300, + height: 300, + }, +} as const; diff --git a/scripts/view/pages-tree/generate-pages-tree.tsx b/scripts/view/pages-tree/generate-pages-tree.tsx new file mode 100644 index 000000000..8b7e7079f --- /dev/null +++ b/scripts/view/pages-tree/generate-pages-tree.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { stylesPageTree } from './generate-pages-tree.styles'; +import { PageData } from '../../types/types'; +import { loadImageAsDataUri } from '../../utils/load-image-as-data-uri'; + +const rsBannerPromise = loadImageAsDataUri('src/shared/assets/svg/RsBanner.svg'); +const rsMascotsPromise = loadImageAsDataUri('src/shared/assets/mentor-with-his-students.webp'); +const mapBgPromise = loadImageAsDataUri('src/shared/assets/map.webp'); + +export async function createPageTree({ + page: { title, description }, +}: PageData): Promise { + const [rsBannerUri, rsMascotsUri, mapBgUri] = await Promise.all([ + rsBannerPromise, + rsMascotsPromise, + mapBgPromise, + ]); + + if (!mapBgUri || !rsMascotsUri || !rsBannerUri) { + console.error('Error loading images'); + return null; + } + + return ( +
+ RS Logo + +
+

{title}

+

{description}

+
+ +
+ RS mascots +
+ +
+ + RS Logo +
+ ); +} diff --git a/src/app/community/page.tsx b/src/app/community/page.tsx index e1abb3461..cee105ebc 100644 --- a/src/app/community/page.tsx +++ b/src/app/community/page.tsx @@ -1,11 +1,27 @@ import { Metadata } from 'next'; +import { OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import Community from '@/views/community'; export async function generateMetadata(): Promise { const title = 'Community · The Rolling Scopes School'; + const description = + 'Join the RS School international developer community: collaborate, learn, share experiences, attend events, and grow your tech career together!'; + const keywords = 'RS School community, developer community, programming community, events, collaboration, tech networking'; + const canonical = 'https://rs.school/community'; + const robots = 'index, follow'; - return { title }; + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/community.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default function CommunityRoute() { diff --git a/src/app/courses/angular/page.tsx b/src/app/courses/angular/page.tsx index 4b0b9d1fd..9de6edf4d 100644 --- a/src/app/courses/angular/page.tsx +++ b/src/app/courses/angular/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { Angular } from '@/views/angular'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,22 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.ANGULAR; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = 'RS School Angular course: for those with JavaScript/TypeScript skills. Learn Angular, build scalable apps, master components, services, and best practices.'; + const keywords = 'Angular course, Angular training, learn Angular, frontend, web development, RS School, TypeScript, компоненты'; + const canonical = 'https://rs.school/courses/angular'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/angular.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function AngularRoute() { diff --git a/src/app/courses/aws-ai/page.tsx b/src/app/courses/aws-ai/page.tsx index 3551200b4..de64b8c5d 100644 --- a/src/app/courses/aws-ai/page.tsx +++ b/src/app/courses/aws-ai/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { AwsAI } from '@/views/aws-ai'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,23 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.AWS_AI; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = + 'RS School AWS AI course: learn AWS AI/ML services, machine learning fundamentals, cloud-based AI solutions, and hands-on projects for real skills.'; + const keywords = 'AWS AI course, machine learning, AWS ML, cloud AI, RS School, artificial intelligence, cloud computing'; + const canonical = 'https://rs.school/courses/aws-ai'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/aws-ai.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function AwsDeveloperRoute() { diff --git a/src/app/courses/aws-cloud-developer/page.tsx b/src/app/courses/aws-cloud-developer/page.tsx index 5eadb392c..14a1aeae1 100644 --- a/src/app/courses/aws-cloud-developer/page.tsx +++ b/src/app/courses/aws-cloud-developer/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { AwsDeveloper } from '@/views/aws-developer'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,22 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.AWS_CLOUD_DEVELOPER; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = 'RS School AWS Cloud Developer: step-by-step training for AWS Certified Developer Associate. Learn AWS services, cloud apps, and hands-on development.'; + const keywords = 'AWS Cloud Developer, AWS developer course, cloud apps, AWS certification, RS School, cloud development'; + const canonical = 'https://rs.school/courses/aws-cloud-developer'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/aws-cloud-developer.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function AwsDeveloperRoute() { diff --git a/src/app/courses/aws-devops/page.tsx b/src/app/courses/aws-devops/page.tsx index c2e90de07..c8e7ff848 100644 --- a/src/app/courses/aws-devops/page.tsx +++ b/src/app/courses/aws-devops/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { AwsDevOps } from '@/views/aws-devops'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,22 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.AWS_DEVOPS; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = 'RS School AWS DevOps: learn AWS DevOps tools, CI/CD, automation, cloud infrastructure, and prepare for a DevOps career with hands-on projects.'; + const keywords = 'AWS DevOps course, DevOps training, AWS tools, CI/CD, cloud automation, RS School, cloud infrastructure'; + const canonical = 'https://rs.school/courses/aws-devops'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/aws-devops.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function AwsDeveloperRoute() { diff --git a/src/app/courses/aws-fundamentals/page.tsx b/src/app/courses/aws-fundamentals/page.tsx index 08401eef2..0a86d0d6d 100644 --- a/src/app/courses/aws-fundamentals/page.tsx +++ b/src/app/courses/aws-fundamentals/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { AwsFundamentals } from '@/views/aws-fundamentals'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,22 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.AWS_FUNDAMENTALS; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = 'RS School AWS Fundamentals: prepare for AWS Certified Cloud Practitioner, learn AWS basics, cloud concepts, security, billing, and core AWS services.'; + const keywords = 'AWS course, AWS fundamentals, cloud practitioner, learn AWS, RS School, cloud basics, cloud certification'; + const canonical = 'https://rs.school/courses/aws-fundamentals'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/aws-fundamentals.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function AwsFundRoute() { diff --git a/src/app/courses/javascript-preschool-ru/page.tsx b/src/app/courses/javascript-preschool-ru/page.tsx index c8db69c23..52a89173f 100644 --- a/src/app/courses/javascript-preschool-ru/page.tsx +++ b/src/app/courses/javascript-preschool-ru/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { JavaScriptPreSchoolRu } from '@/views/javascript-preschool-ru'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,24 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.JS_PRESCHOOL_RU; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = + 'RS School JavaScript Preschool (RU): learn programming basics, problem-solving, and prepare for the main JavaScript/Front-end course. For beginners.'; + const keywords = + 'JavaScript preschool, основы программирования, обучение с нуля, RS School, подготовительный курс, программирование для начинающих'; + const canonical = 'https://rs.school/courses/javascript-preschool-ru'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/js-front-end-pre-school-ru.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function JsPreRoute() { diff --git a/src/app/courses/javascript-ru/page.tsx b/src/app/courses/javascript-ru/page.tsx index 04b0ab2a8..b42b108fc 100644 --- a/src/app/courses/javascript-ru/page.tsx +++ b/src/app/courses/javascript-ru/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { JavaScriptRu } from '@/views/javascript-ru'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,24 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.JS_RU; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = + 'RS School JavaScript (RU): master modern JavaScript, ES6+, async programming, and web development in Russian. Build projects and boost your career!'; + const keywords = + 'JavaScript course, JS обучение, изучить JavaScript, ES6, веб-разработка, RS School, фронтенд, обучение программированию'; + const canonical = 'https://rs.school/courses/javascript-ru'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/js-front-end-ru.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function JsRuRoute() { diff --git a/src/app/courses/javascript/page.tsx b/src/app/courses/javascript/page.tsx index 9b7b8c64e..2f67694cf 100644 --- a/src/app/courses/javascript/page.tsx +++ b/src/app/courses/javascript/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { JavaScriptEn } from '@/views/javascript-en'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,24 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.JS_EN; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = + 'RS School JavaScript course: learn modern JavaScript, ES6+, async programming, and web development. Build real projects and boost your career!'; + const keywords = + 'JavaScript course, JS training, learn JavaScript, ES6, web development, RS School, frontend, coding bootcamp'; + const canonical = 'https://rs.school/courses/javascript'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/js-front-end-en.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function JsEnRoute() { diff --git a/src/app/courses/nodejs/page.tsx b/src/app/courses/nodejs/page.tsx index e8a88f7bf..a92e940bd 100644 --- a/src/app/courses/nodejs/page.tsx +++ b/src/app/courses/nodejs/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { Nodejs } from '@/views/nodejs'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,23 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.NODE; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = + 'RS School Node.js course: for JavaScript/Front-End devs to master Node.js, backend, APIs, databases, and server-side web app development.'; + const keywords = 'Node.js course, Node.js training, backend development, learn Node.js, RS School, web development, серверная разработка'; + const canonical = 'https://rs.school/courses/nodejs'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/nodejs.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function NodeRoute() { diff --git a/src/app/courses/page.tsx b/src/app/courses/page.tsx index 69504f26d..3a10e5cf5 100644 --- a/src/app/courses/page.tsx +++ b/src/app/courses/page.tsx @@ -1,11 +1,27 @@ import { Metadata } from 'next'; +import { OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { Courses } from '@/views/courses'; export async function generateMetadata(): Promise { const title = 'Courses · The Rolling Scopes School'; + const description = + 'Explore free, community-driven RS School courses: JavaScript, React, Node.js, AWS, Angular, and more. Start your journey to full stack mastery!'; + const keywords = 'RS School courses, free programming courses, JavaScript, React, Node.js, AWS, Angular, web development, IT education'; + const canonical = 'https://rs.school/courses'; + const robots = 'index, follow'; - return { title }; + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/courses.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default function CoursesRoute() { diff --git a/src/app/courses/reactjs/page.tsx b/src/app/courses/reactjs/page.tsx index 79f9215e1..82dd771ca 100644 --- a/src/app/courses/reactjs/page.tsx +++ b/src/app/courses/reactjs/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from 'next'; +import { OG_COURSES_FOLDER, OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { getCourseTitle } from '@/shared/helpers/get-course-title'; import { React } from '@/views/react'; import { COURSE_TITLES } from 'data'; @@ -7,7 +9,24 @@ import { COURSE_TITLES } from 'data'; const courseName = COURSE_TITLES.REACT; export async function generateMetadata(): Promise { - return { title: await getCourseTitle(courseName) }; + const title = await getCourseTitle(courseName); + const description = + 'RS School React course: hands-on React.js, hooks, state management, and component architecture. Build scalable apps and master React best practices.'; + const keywords = + 'React course, React training, learn React, React.js, web development, RS School, frontend, hooks, state management'; + const canonical = 'https://rs.school/courses/reactjs'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/${OG_COURSES_FOLDER}/react.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function ReactRoute() { diff --git a/src/app/docs/[lang]/[...slug]/page.tsx b/src/app/docs/[lang]/[...slug]/page.tsx index 6966f6a81..1ec8943d8 100644 --- a/src/app/docs/[lang]/[...slug]/page.tsx +++ b/src/app/docs/[lang]/[...slug]/page.tsx @@ -6,6 +6,8 @@ import { TITLE_POSTFIX } from '../../constants'; import { Menu } from '../../types'; import { fetchMarkdownContent } from '../../utils/fetch-markdown-content'; import { fetchMenu } from '../../utils/fetch-menu'; +import { OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { Language } from '@/shared/types'; type RouteParams = { lang: Language; @@ -44,8 +46,24 @@ export async function generateMetadata({ const slugPath = slug.join('/'); const title = titles.find((el) => el.slug.join('/') === slugPath)?.title; - - return { title: `${title} ${TITLE_POSTFIX}` }; + const description = + 'RS School Docs: access rules, guides, FAQs, onboarding, and resources for students and mentors. Your hub for all Rolling Scopes School documentation.'; + + const keywords = + 'RS School docs, documentation, rules, guides, onboarding, FAQ, student resources, mentor resources'; + const canonical = `https://rs.school/docs/${lang}/${slugPath}`; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title: `${title} ${TITLE_POSTFIX}`, + description, + imagePath: `/${OG_FOLDER}/docs.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export async function generateStaticParams(): Promise { diff --git a/src/app/docs/[lang]/page.tsx b/src/app/docs/[lang]/page.tsx index 8b5a636a9..86598c327 100644 --- a/src/app/docs/[lang]/page.tsx +++ b/src/app/docs/[lang]/page.tsx @@ -1,12 +1,30 @@ import { DocsContent } from '../components/docs-content/docs-content'; import { TITLE_POSTFIX } from '../constants'; import { fetchMarkdownContent } from '../utils/fetch-markdown-content'; +import { OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { Language } from '@/shared/types'; type RouteParams = { lang: Language }; export async function generateMetadata() { - return { title: `RS School Overview ${TITLE_POSTFIX}` }; + const title = `RS School Overview ${TITLE_POSTFIX}`; + const description = + 'RS School Docs: find rules, guides, FAQs, onboarding, and resources for students and mentors. Your hub for all Rolling Scopes School documentation.'; + const keywords = 'RS School docs, documentation, rules, guides, onboarding, FAQ, student resources, mentor resources'; + const canonical = 'https://rs.school/docs'; + const robots = 'index, follow'; + + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/docs.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export async function generateStaticParams(): Promise { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e2c567d23..2da7067b9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import '@/core/styles/index.scss'; import 'react-responsive-carousel/lib/styles/carousel.min.css'; export const metadata: Metadata = { + metadataBase: new URL('https://rs.school'), title: 'RS Site', description: 'RS School offers free, community-driven education courses run by The Rolling Scopes developer community since 2013. Enhance your web development, JavaScript, and front-end skills with us.', diff --git a/src/app/mentorship/[course]/page.tsx b/src/app/mentorship/[course]/page.tsx index ea2a3ef63..2acba773e 100644 --- a/src/app/mentorship/[course]/page.tsx +++ b/src/app/mentorship/[course]/page.tsx @@ -1,12 +1,28 @@ import { Metadata } from 'next'; +import { OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { Mentorship } from '@/views/mentorship/mentorship'; import { MentorshipCourseRouteKeys, mentorshipCourses, mentorshipCoursesDefault } from 'data'; export async function generateMetadata(): Promise { const title = `Mentorship · The Rolling Scopes School`; + const description = + 'RS School Mentorship: mentor React, Angular, or JavaScript students, share expertise, develop leadership, and grow with our global tech community.'; + const keywords = 'RS School mentorship, mentor React, mentor Angular, mentor JavaScript, teaching, tech mentorship, developer mentor'; + const canonical = 'https://rs.school/mentorship'; + const robots = 'index, follow'; - return { title }; + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/mentorship.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export async function generateStaticParams(): Promise<{ course: MentorshipCourseRouteKeys }[]> { return [ diff --git a/src/app/mentorship/page.tsx b/src/app/mentorship/page.tsx index 6c822adc1..32bc27dd1 100644 --- a/src/app/mentorship/page.tsx +++ b/src/app/mentorship/page.tsx @@ -1,12 +1,28 @@ import { Metadata } from 'next'; +import { OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { Mentorship } from '@/views/mentorship/mentorship'; import { mentorshipCoursesDefault } from 'data'; export async function generateMetadata(): Promise { const title = `Mentorship · The Rolling Scopes School`; + const description = + 'RS School Mentorship: share your knowledge, help others grow, develop leadership skills, and learn by teaching in our global tech community.'; + const keywords = 'RS School mentorship, mentor, teaching, leadership, tech mentorship, programming mentor, developer mentor'; + const canonical = 'https://rs.school/mentorship'; + const robots = 'index, follow'; - return { title }; + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/mentorship.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default async function MentorshipRoute() { diff --git a/src/app/page.tsx b/src/app/page.tsx index 3d6fc7a2d..af0c4190f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,27 @@ import { Metadata } from 'next'; +import { OG_FOLDER } from '@/shared/constants'; +import { generatePageMetadata } from '@/shared/helpers/generate-page-metadata'; import { Home } from '@/views/home'; export async function generateMetadata(): Promise { const title = 'Home · The Rolling Scopes School'; + const description = + 'RS School: free, community-driven education for future developers. Learn JavaScript, React, Node.js, AWS, and more. Grow your tech career with us!'; + const keywords = 'RS School, Rolling Scopes, free programming courses, JavaScript, React, Node.js, AWS, web development, IT education, coding bootcamp'; + const canonical = 'https://rs.school/'; + const robots = 'index, follow'; - return { title }; + const metadata = generatePageMetadata({ + title, + description, + imagePath: `/${OG_FOLDER}/home.png`, + keywords, + alternates: { canonical }, + robots, + }); + + return metadata; } export default function HomeRoute() { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a06486074..43cfcf6f2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -7,7 +7,8 @@ export const TO_BE_DETERMINED = 'TBD'; export const REGISTRATION_WILL_OPEN_SOON = 'Registration will open soon!'; export const REGISTRATION_WILL_OPEN_SOON_RU = 'Регистрация откроется скоро!'; export const UNKNOWN_API_ERROR = 'Unknown error, API request failed.'; - +export const OG_FOLDER = 'og'; +export const OG_COURSES_FOLDER = 'courses'; /** * https://www.contentful.com/developers/docs/references/content-preview-api/#/reference/links */ diff --git a/src/shared/helpers/generate-page-metadata.ts b/src/shared/helpers/generate-page-metadata.ts new file mode 100644 index 000000000..f960f6d8b --- /dev/null +++ b/src/shared/helpers/generate-page-metadata.ts @@ -0,0 +1,35 @@ +export function generatePageMetadata({ + title, + description, + imagePath, + keywords, + alternates, + robots, +}: { + title: string; + description: string; + imagePath: string; + keywords?: string; + alternates?: { canonical: string }; + robots?: string; +}) { + return { + title, + description, + ...(keywords && { keywords }), + ...(alternates && { alternates }), + ...(robots && { robots }), + openGraph: { + title, + description, + images: [ + { + url: imagePath, + width: 1200, + height: 630, + alt: title, + }, + ], + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index cd483a552..ca2a6efd9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,11 +2,7 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": [ - "ESNext", - "DOM", - "DOM.Iterable" - ], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ @@ -21,20 +17,11 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "types": [ - "node", - "@testing-library/jest-dom", - "vitest/globals", - "gapi.youtube" - ], + "types": ["node", "@testing-library/jest-dom", "vitest/globals", "gapi.youtube"], "baseUrl": "./src", "paths": { - "@/*": [ - "*" - ], - "data": [ - "../dev-data" - ] + "@/*": ["*"], + "data": ["../dev-data"] }, "allowJs": true, "incremental": true, @@ -53,9 +40,8 @@ "dev-data/**/*.tsx", "./build/types/**/*.ts", ".next/types/**/*.ts", - "env.d.ts" - ], - "exclude": [ - "node_modules" + "env.d.ts", + "scripts/generate-og-script.ts" ], + "exclude": ["node_modules"] }