chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './lib/courses.api';
|
||||
export * from './lib/course.types';
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BlogAuthor } from '@nx/nx-dev-data-access-documents/node-only';
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
authors: BlogAuthor[];
|
||||
repository?: string;
|
||||
lessons: Lesson[];
|
||||
lessonCount?: number;
|
||||
filePath: string;
|
||||
externalLink?: string;
|
||||
totalDuration: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
videoUrl: string;
|
||||
duration: string;
|
||||
filePath: string;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { extractFrontmatter } from '@nx/nx-dev-ui-markdoc';
|
||||
import { readFileSync, lstatSync } from 'fs';
|
||||
import { Course, Lesson } from './course.types';
|
||||
import { calculateTotalDuration } from './duration.utils';
|
||||
|
||||
export class CoursesApi {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
coursesRoot: string;
|
||||
authorsPath: string;
|
||||
}
|
||||
) {
|
||||
if (!options.coursesRoot) {
|
||||
throw new Error('courses root cannot be undefined');
|
||||
}
|
||||
if (!options.authorsPath) {
|
||||
throw new Error('authors path cannot be undefined');
|
||||
}
|
||||
}
|
||||
|
||||
async getAllCourses(): Promise<Course[]> {
|
||||
const courseFolders = await readdir(this.options.coursesRoot);
|
||||
const courses = await Promise.all(
|
||||
courseFolders
|
||||
.filter((directory) => {
|
||||
const stat = lstatSync(join(this.options.coursesRoot, directory));
|
||||
return stat.isDirectory();
|
||||
})
|
||||
.map((folder) => this.getCourse(folder))
|
||||
);
|
||||
return courses.sort((a, b) => {
|
||||
// If both courses have order, sort by order
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
// If only one has order, prioritize the one with order
|
||||
if (a.order !== undefined) return -1;
|
||||
if (b.order !== undefined) return 1;
|
||||
// If neither has order, sort by id (folder name)
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
async getCourse(folderName: string): Promise<Course> {
|
||||
const authors = JSON.parse(readFileSync(this.options.authorsPath, 'utf8'));
|
||||
const coursePath = join(this.options.coursesRoot, folderName);
|
||||
const courseFilePath = join(coursePath, 'course.md');
|
||||
|
||||
const content = await readFile(courseFilePath, 'utf-8');
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
|
||||
let lessons: Lesson[] = [];
|
||||
if (!frontmatter.externalLink) {
|
||||
const lessonFolders = await readdir(coursePath);
|
||||
const tmpLessons = await Promise.all(
|
||||
lessonFolders
|
||||
.filter((folder) => {
|
||||
const stat = lstatSync(join(coursePath, folder));
|
||||
return stat.isDirectory();
|
||||
})
|
||||
.map((folder) => this.getLessons(folderName, folder))
|
||||
);
|
||||
lessons = tmpLessons.flat();
|
||||
}
|
||||
|
||||
return {
|
||||
id: folderName,
|
||||
title: frontmatter.title,
|
||||
description: frontmatter.description,
|
||||
content,
|
||||
authors: authors.filter((author: { name: string }) =>
|
||||
frontmatter.authors.includes(author.name)
|
||||
),
|
||||
repository: frontmatter.repository,
|
||||
lessons,
|
||||
filePath: courseFilePath,
|
||||
totalDuration: calculateTotalDuration(lessons),
|
||||
lessonCount: frontmatter.lessonCount,
|
||||
externalLink: frontmatter.externalLink,
|
||||
order: frontmatter.order,
|
||||
};
|
||||
}
|
||||
|
||||
private async getLessons(
|
||||
courseId: string,
|
||||
lessonFolder: string
|
||||
): Promise<Lesson[]> {
|
||||
const lessonPath = join(this.options.coursesRoot, courseId, lessonFolder);
|
||||
const lessonFiles = await readdir(lessonPath);
|
||||
|
||||
const lessons = await Promise.all(
|
||||
lessonFiles.map(async (file) => {
|
||||
if (!file.endsWith('.md')) return null;
|
||||
const filePath = join(lessonPath, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
if (!frontmatter || !frontmatter.title) {
|
||||
throw new Error(`Lesson ${lessonFolder}/${file} has no title`);
|
||||
}
|
||||
return {
|
||||
id: `${lessonFolder}-${file.replace('.md', '')}`,
|
||||
title: frontmatter.title,
|
||||
description: content,
|
||||
videoUrl: frontmatter.videoUrl || null,
|
||||
duration: frontmatter.duration || null,
|
||||
filePath,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return lessons.filter((lesson): lesson is Lesson => lesson !== null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Lesson } from './course.types';
|
||||
|
||||
export function calculateTotalDuration(lessons: Lesson[]): string {
|
||||
const totalMinutes = lessons.reduce((total, lesson) => {
|
||||
if (!lesson.duration) return total;
|
||||
const [minutes, seconds] = lesson.duration.split(':').map(Number);
|
||||
return total + minutes + seconds / 60;
|
||||
}, 0);
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
Reference in New Issue
Block a user