diff --git a/docs/courses/explore-nx/course.md b/docs/courses/explore-nx/course.md new file mode 100644 index 0000000000..14443cc1fb --- /dev/null +++ b/docs/courses/explore-nx/course.md @@ -0,0 +1,7 @@ +--- +title: 'Introduction to Nx' +description: 'New to Nx? Then this is where you should start.' +authors: [Juri Strumpflohner] +--- + +This course gives you a quick high-level overview of Nx, how running tasks works, task caching, how Nx provides code scaffolding functionality and how you can use `nx migrate` to automatically update your workspace dependencies and code across breaking changes. diff --git a/docs/courses/explore-nx/lessons/01-why-nx.md b/docs/courses/explore-nx/lessons/01-why-nx.md new file mode 100644 index 0000000000..13868e98ce --- /dev/null +++ b/docs/courses/explore-nx/lessons/01-why-nx.md @@ -0,0 +1,19 @@ +--- +title: 'Soo..what is Nx?' +videoUrl: 'https://youtu.be/-_4WMl-Fn0w' +duration: '9:28' +--- + +This video gives you a birds-eye view of Nx in ~10 minutes. It covers topics such as: + +- What is Nx +- Nx Architecture +- Add Nx to an arbitrary project +- Why would adding Nx be useful? +- Nx in a PNPM monorepo +- Why use Nx Plugins +- Setting up a new Nx Integrated Monorepo +- Abstracting low-level configs +- Automated Code Updates + +Read more [in our docs](/getting-started/why-nx) diff --git a/docs/courses/explore-nx/lessons/02-run-tasks.md b/docs/courses/explore-nx/lessons/02-run-tasks.md new file mode 100644 index 0000000000..abd42ceb06 --- /dev/null +++ b/docs/courses/explore-nx/lessons/02-run-tasks.md @@ -0,0 +1,14 @@ +--- +title: 'Run Tasks with Nx' +videoUrl: 'https://youtu.be/aEdfYiA5U34' +duration: '4:19' +--- + +Learn how Nx provides a powerful task runner that allows you to: + +- easily run multiple targets for multiple projects in parallel +- define task pipelines to run tasks in the correct order +- only run tasks for projects affected by a given change +- speed up task execution with caching + +Read more [in our docs](/features/run-tasks) diff --git a/docs/courses/explore-nx/lessons/03-cache-task-results.md b/docs/courses/explore-nx/lessons/03-cache-task-results.md new file mode 100644 index 0000000000..9491d3b21f --- /dev/null +++ b/docs/courses/explore-nx/lessons/03-cache-task-results.md @@ -0,0 +1,12 @@ +--- +title: 'Cache Task Results' +videoUrl: 'https://youtu.be/o-6jb78uuP0' +duration: '8:50' +--- + +Learn how Nx's sophisticated caching system ensures code is never rebuilt twice. This: + +- drastically speeds up your task execution times while developing locally and in CI +- saves you money on CI/CD costs by reducing the number of tasks that need to be executed + +Read more [in our docs](/features/cache-task-results) diff --git a/docs/courses/explore-nx/lessons/04-generate-code.md b/docs/courses/explore-nx/lessons/04-generate-code.md new file mode 100644 index 0000000000..72d96151e4 --- /dev/null +++ b/docs/courses/explore-nx/lessons/04-generate-code.md @@ -0,0 +1,13 @@ +--- +title: 'Generate Code' +videoUrl: 'https://youtu.be/hSM6MgWOYr8' +duration: '4:11' +--- + +Learn how Nx's code generators help boost your productivity by: + +- Allowing you to scaffold new projects or augment existing projects with new features +- Automating repetitive tasks in your development workflow +- Ensuring your code is consistent and follows best practices + +Read more [in our docs](/features/generate-code) diff --git a/docs/courses/explore-nx/lessons/05-automate-updating-dependencies.md b/docs/courses/explore-nx/lessons/05-automate-updating-dependencies.md new file mode 100644 index 0000000000..4f6231e105 --- /dev/null +++ b/docs/courses/explore-nx/lessons/05-automate-updating-dependencies.md @@ -0,0 +1,13 @@ +--- +title: 'Automate Updating Dependencies' +videoUrl: 'https://youtu.be/A0FjwsTlZ8A' +duration: '4:45' +--- + +Learn how Nx migrate functionality helps you: + +- automatically update your package.json dependencies +- migrate your configuration files (e.g. Jest, ESLint, Nx config) +- adjust your source code to match the new versions of packages + +Read more [in our docs](/features/automate-updating-dependencies) diff --git a/docs/courses/pnpm-nx-next/course.md b/docs/courses/pnpm-nx-next/course.md new file mode 100644 index 0000000000..0676563ab0 --- /dev/null +++ b/docs/courses/pnpm-nx-next/course.md @@ -0,0 +1,17 @@ +--- +title: 'From PNPM Workspaces to Distributed CI' +description: 'Learn how to transform a PNPM workspace monorepo into a high-performance distributed CI setup using Nx.' +authors: [Juri Strumpflohner] +repository: 'https://github.com/nrwl/nx-course-pnpm-nx' +--- + +In this course, we'll walk through a step-by-step guide using the Tasker application as our example. Tasker is a task management app built with Next.js, structured as a PNPM workspace monorepo. The monorepo contains the Next.js application which is modularized into packages that handle data access via Prisma to a local DB, UI components, and more. + +Throughout the course, we'll take incremental steps to enhance the monorepo: + +1. Adding Nx +2. Configuring and fine-tuning local caching +3. Defining task pipelines to ensure correct task execution order +4. Optimizing CI configuration with remote caching +5. Adjusting the current CI configuration to enable task distribution +6. Splitting and parallelizing Playwright e2e tests to reduce execution time from 20 minutes to 9 minutes diff --git a/docs/courses/pnpm-nx-next/images/e2e-splitting-anim.gif b/docs/courses/pnpm-nx-next/images/e2e-splitting-anim.gif new file mode 100644 index 0000000000..a174c78bb3 Binary files /dev/null and b/docs/courses/pnpm-nx-next/images/e2e-splitting-anim.gif differ diff --git a/docs/courses/pnpm-nx-next/images/implicit-dependencies.avif b/docs/courses/pnpm-nx-next/images/implicit-dependencies.avif new file mode 100644 index 0000000000..bf84e873ea Binary files /dev/null and b/docs/courses/pnpm-nx-next/images/implicit-dependencies.avif differ diff --git a/docs/courses/pnpm-nx-next/images/nx-cloud-compare-cache-miss.avif b/docs/courses/pnpm-nx-next/images/nx-cloud-compare-cache-miss.avif new file mode 100644 index 0000000000..18e617d973 Binary files /dev/null and b/docs/courses/pnpm-nx-next/images/nx-cloud-compare-cache-miss.avif differ diff --git a/docs/courses/pnpm-nx-next/lessons/00-overview.md b/docs/courses/pnpm-nx-next/lessons/00-overview.md new file mode 100644 index 0000000000..0a0ff22392 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/00-overview.md @@ -0,0 +1,16 @@ +--- +title: 'Course Intro' +videoUrl: 'https://youtu.be/VJ1v5dktwwI' +duration: '1:01' +--- + +In this course, we'll walk through a step-by-step guide using the Tasker application as our example. Tasker is a task management app built with Next.js, structured as a PNPM workspace monorepo. The monorepo contains the Next.js application which is modularized into packages that handle data access via Prisma to a local DB, UI components, and more. + +Throughout the course, we'll take incremental steps to enhance the monorepo: + +1. Adding Nx +2. Configuring and fine-tuning local caching +3. Defining task pipelines to ensure correct task execution order +4. Optimizing CI configuration with remote caching +5. Implementing distribution across machines +6. Optimizing Playwright e2e tests to reduce execution time from 20 minutes to 9 minutes diff --git a/docs/courses/pnpm-nx-next/lessons/01-nx-init.md b/docs/courses/pnpm-nx-next/lessons/01-nx-init.md new file mode 100644 index 0000000000..d8430f5c13 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/01-nx-init.md @@ -0,0 +1,18 @@ +--- +title: 'Initialize Nx in Your Project with nx init' +videoUrl: 'https://youtu.be/3hW53b1IJ84' +duration: '3:42' +--- + +In this lesson, we'll explore how to add Nx to our existing PNPM workspace. You can either add just the `nx` package to your `package.json` and then create a `nx.json` [configuration file](/reference/nx-json), or simply run: + +```shell +nx init +``` + +This process will analyze your repository and ask you a couple of questions to properly set up Nx while maintaining your existing PNPM workspace structure. + +## Relevant Links + +- [Adopting Nx](/recipes/adopting-nx) +- [Import an Existing Project into an Nx Workspace](/recipes/adopting-nx/import-project) diff --git a/docs/courses/pnpm-nx-next/lessons/02-run-tasks.md b/docs/courses/pnpm-nx-next/lessons/02-run-tasks.md new file mode 100644 index 0000000000..9113099901 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/02-run-tasks.md @@ -0,0 +1,23 @@ +--- +title: 'Run and Manage Tasks Efficiently Using Nx' +videoUrl: 'https://youtu.be/CJLRkzRrcjg' +duration: '1:56' +--- + +In this lesson, you'll learn how to use Nx to run your PNPM workspace's `package.json` scripts. So rather than running: + +```shell +pnpm --filter @tasker/web build +``` + +you would run: + +```shell +pnpm nx build @tasker/web +``` + +We'll cover the syntax for running both single tasks and multiple tasks, helping you understand how to leverage Nx's task execution capabilities. + +## Relevant Links + +- [Run Tasks with Nx](/features/run-tasks) diff --git a/docs/courses/pnpm-nx-next/lessons/03-configure-cache.md b/docs/courses/pnpm-nx-next/lessons/03-configure-cache.md new file mode 100644 index 0000000000..3162df96e1 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/03-configure-cache.md @@ -0,0 +1,13 @@ +--- +title: 'Configure Cache Outputs to Handle the .next Folder' +videoUrl: 'https://youtu.be/t8lOa__TD7o' +duration: '2:07' +--- + +By default Nx captures common folders like `dist` or `build` and automatically restores them from the local cache. However, it doesn't capture the `.next` folder by default. + +In this lesson, you'll learn how to fine-tune local caching to ensure proper handling of the `.next` folder. We'll configure the cache outputs to make sure the Next.js build artifacts are properly restored from cache when needed. + +## Relevant Links + +- [Cache Task Results](/features/cache-task-results) diff --git a/docs/courses/pnpm-nx-next/lessons/04-task-pipelines.md b/docs/courses/pnpm-nx-next/lessons/04-task-pipelines.md new file mode 100644 index 0000000000..77f63564e6 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/04-task-pipelines.md @@ -0,0 +1,28 @@ +--- +title: 'Automate Task Pipelines to Build Before next start' +videoUrl: 'https://youtu.be/_U4hu6SuBaY' +duration: '3:07' +--- + +All Next.js projects usually come with these `package.json` scripts: + +```json {% fileName="package.json" %} +{ + ... + "scripts": { + ... + "build": "next build", + "start": "next start" + } +} +``` + +Running `next start` will only work if the `.next` folder is present in the project's root. This folder is created when running `next build`. + +This is a very simple use case of a [task pipeline](/concepts/task-pipeline-configuration), which defines dependencies among tasks. + +In this lesson we're going to create a simple task pipeline such that whenever you run `next start`, Nx will automatically run `next build` (or restore it from the cache). + +## Relevant Links + +- [Defining a Task Pipeline](/recipes/running-tasks/defining-task-pipeline) diff --git a/docs/courses/pnpm-nx-next/lessons/05-implicit-dependencies.md b/docs/courses/pnpm-nx-next/lessons/05-implicit-dependencies.md new file mode 100644 index 0000000000..125c7b9c49 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/05-implicit-dependencies.md @@ -0,0 +1,27 @@ +--- +title: 'Link an e2e Project with Its Web App Through Implicit Dependencies' +videoUrl: 'https://youtu.be/-iUHY27qUfE' +duration: '2:38' +--- + +One of the main capabilities of Nx is that it builds a project graph behind the scenes which it uses optimize how it runs your tasks. You can visualize the graph using: + +```shell +pnpm nx graph +``` + +{% callout type="info" title="Install Nx Console" %} + +You can also install **Nx Console** which is an extension for VSCode and IntelliJ that enhances the DX when working with Nx monorepos among which there's also the ability to visualize the project graph right in your editor window. Read more [about it here](/getting-started/editor-setup). + +{% /callout %} + +While most of the relationships are discovered by Nx automatically via `package.json` dependencies or JS/TypeScript imports, some cannot be detected. E2E projects such as the Playwright project in our workspace doesn't directly depend on our Next.js application. There is a dependency at runtime though, because Playwright needs to serve our Next application in order to be ablet to run its e2e tests. + +![Implicit dependencies](/courses/pnpm-nx-next/images/implicit-dependencies.avif) + +In this lesson, you'll learn how to define such dependencies using the `implicitDependencies` property. + +## Relevant Links + +- [Project configuration: implicitDependencies](/reference/project-configuration#implicitdependencies) diff --git a/docs/courses/pnpm-nx-next/lessons/06-nx-cloud-setup.md b/docs/courses/pnpm-nx-next/lessons/06-nx-cloud-setup.md new file mode 100644 index 0000000000..f7ed8ab2fa --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/06-nx-cloud-setup.md @@ -0,0 +1,13 @@ +--- +title: 'Connect Your Workspace to Nx Cloud' +videoUrl: 'https://youtu.be/8mqHXYIl_qI' +duration: '4:00' +--- + +Nx powers the “Smart Monorepo,” while Nx Cloud brings “Fast CI” into the mix. Designed to extend Nx’s efficiency into the CI pipeline, Nx Cloud ensures that even large monorepos stay fast and optimized in CI. + +In this lesson, we’ll take the Tasker monorepo, push it to GitHub, set up an Nx Cloud workspace, and link it with your GitHub repository. By the end, your Nx workspace will be fully connected to Nx Cloud, ready to leverage its remote caching and distributed CI capabilities. + +## Relevant Links + +- [Connect to Nx Cloud](/ci/intro/connect-to-nx-cloud) diff --git a/docs/courses/pnpm-nx-next/lessons/07-optimize-ci.md b/docs/courses/pnpm-nx-next/lessons/07-optimize-ci.md new file mode 100644 index 0000000000..ae1fdf1d41 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/07-optimize-ci.md @@ -0,0 +1,20 @@ +--- +title: 'Use Nx Commands on CI' +videoUrl: 'https://youtu.be/ywlilx9-jNk' +duration: '4:43' +--- + +The Tasker project already uses a CI script on GitHub Actions, but in this lesson, we’ll enhance it by replacing the existing `pnpm --filter` commands with optimized Nx commands for a more efficient CI pipeline. + +We’ll cover how to scaffold a new CI configuration with: + +```shell +pnpm nx g ci-workflow +``` + +We’ll also take a quick detour to discuss `namedInputs` in `nx.json`, ensuring the cache invalidates appropriately whenever the CI config is updated. + +## Relevant Links + +- [Run Only Tasks Affected by a PR](/ci/features/affected) +- [Tutorial: Github Actions with Nx](/ci/intro/tutorials/github-actions#create-a-ci-workflow) diff --git a/docs/courses/pnpm-nx-next/lessons/08-remote-caching.md b/docs/courses/pnpm-nx-next/lessons/08-remote-caching.md new file mode 100644 index 0000000000..34e686b39d --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/08-remote-caching.md @@ -0,0 +1,13 @@ +--- +title: 'Configure CI to Access Remote Caching' +videoUrl: 'https://youtu.be/vBokLJ_F8qs' +duration: '1:45' +--- + +Nx Cloud comes with powerful built-in [remote caching capabilities](/ci/features/remote-cache). Security and access control for such a cache is crucial, which is why Nx Cloud provides [various controls for managing read and write access to the remote cache](/ci/recipes/security/access-tokens). + +In this lesson, we'll create an access token in our Nx Cloud workspace configuration to enable read/write access to our Github actions. + +## Relevant Links + +- [Nx CLI and CI Access Tokens](/ci/recipes/security/access-tokens) diff --git a/docs/courses/pnpm-nx-next/lessons/09-debug-cache.md b/docs/courses/pnpm-nx-next/lessons/09-debug-cache.md new file mode 100644 index 0000000000..9759885033 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/09-debug-cache.md @@ -0,0 +1,15 @@ +--- +title: 'Debug Remote Cache misses with Nx Cloud' +videoUrl: 'https://youtu.be/zJmhW1iIxpc' +duration: '0:53' +--- + +Understanding what causes remote cache misses versus cache hits is crucial for optimization. + +![Nx Cloud UI to compare cache misses](/courses/pnpm-nx-next/images/nx-cloud-compare-cache-miss.avif) + +In this lesson, we'll explore how Nx Cloud enables you to compare runs and identify what changes led to cache misses. + +## Relevant Links + +- [Troubleshoot cache misses](/troubleshooting/troubleshoot-cache-misses) diff --git a/docs/courses/pnpm-nx-next/lessons/10-nx-login.md b/docs/courses/pnpm-nx-next/lessons/10-nx-login.md new file mode 100644 index 0000000000..718a181f95 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/10-nx-login.md @@ -0,0 +1,23 @@ +--- +title: 'Enable Remote Caching for your Developer Machine with Nx Login' +videoUrl: 'https://youtu.be/vX-wgI1zlao' +duration: '1:38' +--- + +Do you want to allow your developers working on the Tasker monorepo + +- to benefit from remote cache results (read-only access) +- to also contribute to the remote cache (read/write access) + +It really depends on your use case. Nx Cloud uses Personal Access Tokens (PAT) to give you a fine-grained control mechanism how local workspaces should access the remote cache. + +In this lesson, we'll dive into how to configure your Personal Access Token permissions on Nx Cloud and how developers can authenticate with the Nx Cloud workspace using: + +```shell +pnpm nx login +``` + +## Relevant Links + +- [Nx Cloud and Personal Access Tokens](/ci/recipes/security/personal-access-tokens) +- [Blog: Better security with Personal Access Tokens](/blog/personal-access-tokens) diff --git a/docs/courses/pnpm-nx-next/lessons/11-nx-agents.md b/docs/courses/pnpm-nx-next/lessons/11-nx-agents.md new file mode 100644 index 0000000000..83622292bf --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/11-nx-agents.md @@ -0,0 +1,19 @@ +--- +title: 'Run Tasks in Parallel on Different Machines on CI' +videoUrl: 'https://youtu.be/lO_p4tA6IZI' +duration: '2:08' +--- + +While remote caching is powerful, it may not be enough when core packages change frequently, invalidating the cache for large portions of your workspace. + +Nx Cloud comes with a built-in feature called [Nx Agents](/ci/features/distribute-task-execution) that allows to automatically distribute tasks across multiple machines. + +In this lesson we're going to update the existing CI configuration to enable Nx Agents. Which mostly can be done by adding the following line: + +```plaintext +nx-cloud start-ci-run --distribute-on="5 linux-medium-js" +``` + +## Relevant Links + +- [Distribute Task Execution](/ci/features/distribute-task-execution) diff --git a/docs/courses/pnpm-nx-next/lessons/12-playwright-split.md b/docs/courses/pnpm-nx-next/lessons/12-playwright-split.md new file mode 100644 index 0000000000..8493f5aae2 --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/12-playwright-split.md @@ -0,0 +1,15 @@ +--- +title: 'Split Playwright e2e Tests for a Faster CI' +videoUrl: 'https://youtu.be/42XnmzxEXM8' +duration: '5:47' +--- + +Running e2e tests on CI can be quite a painful experience. You want them to run on each PR to get immediate feedback, but then you don't want to wait for 30 minutes. + +In this lesson, we'll optimize the existing Playwright end-to-end tests that currently take up to 20 minutes on CI. We'll leverage the Nx Playwright plugin to automatically split the Playwright tests into individual runs per test, allowing for optimal distribution across Nx agents and significantly improving CI execution time. + +![](/courses/pnpm-nx-next/images/e2e-splitting-anim.gif) + +## Relevant Links + +- [Automatically Split E2E Tasks](/ci/features/split-e2e-tasks) diff --git a/docs/courses/pnpm-nx-next/lessons/13-outro.md b/docs/courses/pnpm-nx-next/lessons/13-outro.md new file mode 100644 index 0000000000..19bb3afa3d --- /dev/null +++ b/docs/courses/pnpm-nx-next/lessons/13-outro.md @@ -0,0 +1,7 @@ +--- +title: 'Course Outro' +videoUrl: 'https://youtu.be/a_pfLrvf88E' +duration: '0:39' +--- + +Thank you for completing this course on optimizing your PNPM workspace with Nx. You've learned how to implement and configure Nx, set up efficient caching, optimize CI processes, and improve e2e test execution times. diff --git a/docs/shared/getting-started/intro.md b/docs/shared/getting-started/intro.md index b5a2522751..aece323ba6 100644 --- a/docs/shared/getting-started/intro.md +++ b/docs/shared/getting-started/intro.md @@ -53,7 +53,7 @@ Also, here are some recipes that give you more details based on the technology s {% link-card title="What is Nx Cloud?" type="video" url="https://youtu.be/4VI-q943J3o" icon="nxcloud" /%} -{% link-card title="PNPM Monorepos with Nx" type="video" url="https://youtu.be/ngdoUQBvAjo" icon="pnpm" /%} +{% link-card title="PNPM Workspaces to Distributed CI" type="course" url="/courses/pnpm-nx-next" icon="pnpm" /%} {% link-card title="More On Youtube" type="video" url="https://www.youtube.com/@nxdevtools" icon="youtube" /%} diff --git a/nx-dev/data-access-courses/project.json b/nx-dev/data-access-courses/project.json new file mode 100644 index 0000000000..5440d255d0 --- /dev/null +++ b/nx-dev/data-access-courses/project.json @@ -0,0 +1,8 @@ +{ + "name": "data-access-courses", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "nx-dev/data-access-courses/src", + "projectType": "library", + "targets": {}, + "tags": [] +} diff --git a/nx-dev/data-access-courses/src/index.ts b/nx-dev/data-access-courses/src/index.ts new file mode 100644 index 0000000000..f33606d8b9 --- /dev/null +++ b/nx-dev/data-access-courses/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/courses.api'; +export * from './lib/course.types'; diff --git a/nx-dev/data-access-courses/src/lib/course.types.ts b/nx-dev/data-access-courses/src/lib/course.types.ts new file mode 100644 index 0000000000..a060cee6bd --- /dev/null +++ b/nx-dev/data-access-courses/src/lib/course.types.ts @@ -0,0 +1,22 @@ +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[]; + filePath: string; + totalDuration: string; +} + +export interface Lesson { + id: string; + title: string; + description: string; + videoUrl: string; + duration: string; + filePath: string; +} diff --git a/nx-dev/data-access-courses/src/lib/courses.api.ts b/nx-dev/data-access-courses/src/lib/courses.api.ts new file mode 100644 index 0000000000..694a62830c --- /dev/null +++ b/nx-dev/data-access-courses/src/lib/courses.api.ts @@ -0,0 +1,92 @@ +import { readFile, readdir } from 'fs/promises'; +import { join } from 'path'; +import { extractFrontmatter } from '@nx/nx-dev/ui-markdoc'; +import { readFileSync } from 'fs'; +import { Course, Lesson } from './course.types'; +import { calculateTotalDuration } from './duration.utils'; + +export class CoursesApi { + // TODO: move to shared lib + private readonly blogRoot = 'public/documentation/blog'; + + constructor( + private readonly options: { + coursesRoot: string; + } + ) { + if (!options.coursesRoot) { + throw new Error('courses root cannot be undefined'); + } + } + + async getAllCourses(): Promise { + const courseFolders = await readdir(this.options.coursesRoot); + const courses = await Promise.all( + courseFolders.map((folder) => this.getCourse(folder)) + ); + return courses; + } + + async getCourse(folderName: string): Promise { + const authors = JSON.parse( + readFileSync(join(this.blogRoot, 'authors.json'), '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); + + const lessonFolders = await readdir(coursePath); + const lessons = await Promise.all( + lessonFolders + .filter((folder) => folder !== 'course.md') + .map((folder) => this.getLessons(folderName, folder)) + ); + const flattenedLessons = lessons.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: flattenedLessons, + filePath: courseFilePath, + totalDuration: calculateTotalDuration(flattenedLessons), + }; + } + + private async getLessons( + courseId: string, + lessonFolder: string + ): Promise { + 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); + } +} diff --git a/nx-dev/data-access-courses/src/lib/duration.utils.ts b/nx-dev/data-access-courses/src/lib/duration.utils.ts new file mode 100644 index 0000000000..d6341ccbda --- /dev/null +++ b/nx-dev/data-access-courses/src/lib/duration.utils.ts @@ -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`; +} diff --git a/nx-dev/data-access-courses/tsconfig.json b/nx-dev/data-access-courses/tsconfig.json new file mode 100644 index 0000000000..95cfeb243d --- /dev/null +++ b/nx-dev/data-access-courses/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/nx-dev/data-access-courses/tsconfig.lib.json b/nx-dev/data-access-courses/tsconfig.lib.json new file mode 100644 index 0000000000..461061d0b2 --- /dev/null +++ b/nx-dev/data-access-courses/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/nx-dev/nx-dev/app/courses/[courseId]/[lessonId]/page.tsx b/nx-dev/nx-dev/app/courses/[courseId]/[lessonId]/page.tsx new file mode 100644 index 0000000000..8e55992726 --- /dev/null +++ b/nx-dev/nx-dev/app/courses/[courseId]/[lessonId]/page.tsx @@ -0,0 +1,51 @@ +import { coursesApi } from '../../../../lib/courses.api'; +import { DefaultLayout } from '@nx/nx-dev/ui-common'; +import { LessonPlayer } from '@nx/nx-dev/ui-courses'; +import { Metadata } from 'next'; + +interface LessonPageProps { + params: { courseId: string; lessonId: string }; +} + +export async function generateMetadata({ + params, +}: LessonPageProps): Promise { + const course = await coursesApi.getCourse(params.courseId); + const lesson = course.lessons.find((l) => l.id === params.lessonId); + + if (!lesson) { + return { + title: 'Lesson Not Found', + }; + } + + return { + title: `${lesson.title} | ${course.title} | Nx Courses`, + description: lesson.description.substring(0, 160), + }; +} + +export async function generateStaticParams() { + const courses = await coursesApi.getAllCourses(); + return courses.flatMap((course) => + course.lessons.map((lesson) => ({ + courseId: course.id, + lessonId: lesson.id, + })) + ); +} + +export default async function LessonPage({ params }: LessonPageProps) { + const course = await coursesApi.getCourse(params.courseId); + const lesson = course.lessons.find((l) => l.id === params.lessonId); + + if (!lesson) { + return
Lesson not found
; + } + + return ( + + + + ); +} diff --git a/nx-dev/nx-dev/app/courses/[courseId]/page.tsx b/nx-dev/nx-dev/app/courses/[courseId]/page.tsx new file mode 100644 index 0000000000..b85a916738 --- /dev/null +++ b/nx-dev/nx-dev/app/courses/[courseId]/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata, ResolvingMetadata } from 'next'; +import { coursesApi } from '../../../lib/courses.api'; +import { CourseDetails } from '@nx/nx-dev/ui-courses'; +import { DefaultLayout } from '@nx/nx-dev/ui-common'; + +interface CourseDetailProps { + params: { courseId: string }; +} + +export async function generateMetadata( + { params: { courseId } }: CourseDetailProps, + parent: ResolvingMetadata +): Promise { + const course = await coursesApi.getCourse(courseId); + const previousImages = (await parent).openGraph?.images ?? []; + + return { + title: `${course.title} | Nx Courses`, + description: course.description, + openGraph: { + url: `https://nx.dev/courses/${courseId}`, + title: course.title, + description: course.description, + images: [ + { + url: '/path/to/default/course/image.png', // Add a default course image + width: 800, + height: 421, + alt: 'Nx Course: ' + course.title, + type: 'image/png', + }, + ...previousImages, + ], + }, + }; +} + +export async function generateStaticParams() { + const courses = await coursesApi.getAllCourses(); + return courses.map((course) => { + return { courseId: course.id }; + }); +} + +export default async function CourseDetail({ + params: { courseId }, +}: CourseDetailProps) { + const course = await coursesApi.getCourse(courseId); + return course ? ( + <> + {/* This empty div is necessary as app router does not automatically scroll on route changes */} +
+ + + + + ) : null; +} diff --git a/nx-dev/nx-dev/app/courses/page.tsx b/nx-dev/nx-dev/app/courses/page.tsx new file mode 100644 index 0000000000..359c401117 --- /dev/null +++ b/nx-dev/nx-dev/app/courses/page.tsx @@ -0,0 +1,41 @@ +import { DefaultLayout } from '@nx/nx-dev/ui-common'; +import { CourseOverview, CourseHero } from '@nx/nx-dev/ui-video-courses'; +import { coursesApi } from '../../lib/courses.api'; + +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Nx Video Courses', + description: + 'Master Nx with expert-led video courses from the core team. Boost your skills and productivity.', + openGraph: { + url: 'https://nx.dev/courses', + title: 'Nx Video Courses', + description: + 'Master Nx with expert-led video courses from the core team. Boost your skills and productivity.', + images: [ + { + url: 'https://nx.dev/socials/nx-courses-media.png', + width: 800, + height: 421, + alt: 'Nx Video Courses', + type: 'image/jpeg', + }, + ], + siteName: 'Nx', + type: 'website', + }, +}; + +export default async function CoursesPage(): Promise { + const courses = await coursesApi.getAllCourses(); + + return ( + + +
+ +
+
+ ); +} diff --git a/nx-dev/nx-dev/lib/courses.api.ts b/nx-dev/nx-dev/lib/courses.api.ts new file mode 100644 index 0000000000..b14b1d835a --- /dev/null +++ b/nx-dev/nx-dev/lib/courses.api.ts @@ -0,0 +1,5 @@ +import { CoursesApi } from '@nx/nx-dev/data-access-courses'; + +export const coursesApi = new CoursesApi({ + coursesRoot: 'public/documentation/courses', +}); diff --git a/nx-dev/nx-dev/tailwind.config.js b/nx-dev/nx-dev/tailwind.config.js index 5ac8e9c374..2e5639de39 100644 --- a/nx-dev/nx-dev/tailwind.config.js +++ b/nx-dev/nx-dev/tailwind.config.js @@ -94,6 +94,9 @@ module.exports = { }, }, }, + screens: { + wide: '1800px', + }, }, }, plugins: [ diff --git a/nx-dev/ui-common/src/lib/default-layout.tsx b/nx-dev/ui-common/src/lib/default-layout.tsx index 2f3c27c3a5..67e131d9e8 100644 --- a/nx-dev/ui-common/src/lib/default-layout.tsx +++ b/nx-dev/ui-common/src/lib/default-layout.tsx @@ -6,10 +6,16 @@ import cx from 'classnames'; export function DefaultLayout({ isHome = false, children, -}: { isHome?: boolean } & PropsWithChildren): JSX.Element { + hideHeader = false, + hideFooter = false, +}: { + isHome?: boolean; + hideHeader?: boolean; + hideFooter?: boolean; +} & PropsWithChildren): JSX.Element { return (
-
+ {!hideHeader &&
}
-
{children}
+
+ {children} +
-
+
); } diff --git a/nx-dev/ui-common/src/lib/footer.tsx b/nx-dev/ui-common/src/lib/footer.tsx index b63d85a81c..3170c0e000 100644 --- a/nx-dev/ui-common/src/lib/footer.tsx +++ b/nx-dev/ui-common/src/lib/footer.tsx @@ -3,124 +3,127 @@ import { ThemeSwitcher } from '@nx/nx-dev/ui-theme'; import Link from 'next/link'; import { DiscordIcon } from './discord-icon'; -export function Footer(): JSX.Element { - const navigation = { - nx: [ - { name: 'Status', href: 'https://status.nx.app' }, - { name: 'Security', href: 'https://security.nx.app' }, - ], - nxCloud: [ - { name: 'App', href: 'https://cloud.nx.app' }, - { name: 'Docs', href: '/ci/intro/ci-with-nx' }, - { name: 'Pricing', href: '/pricing' }, - ], - solutions: [ - { name: 'Nx', href: 'https://nx.dev' }, - { name: 'Nx Cloud', href: '/nx-cloud' }, - { name: 'Nx Enterprise', href: '/enterprise' }, - ], - resources: [ - { name: 'Blog', href: '/blog' }, - { - name: 'Youtube', - href: 'https://youtube.com/@nxdevtools', - }, - { - name: 'Community', - href: '/community', - }, - { - name: 'Customers', - href: '/customers', - }, - ], - company: [ - { name: 'About us', href: '/company' }, - { name: 'Careers', href: '/careers' }, - { - name: 'Brands & Guidelines', - href: '/brands', - }, - { name: 'Contact us', href: '/contact' }, - ], - social: [ - { - name: 'Discord', - label: 'Community channel', - href: 'https://go.nx.dev/community', - icon: (props: any) => , - }, - { - name: 'GitHub', - label: 'Nx is open source, check the code on GitHub', - href: 'https://github.com/nrwl/nx?utm_source=nx.dev', - icon: (props: any) => ( - - {/*GitHub*/} - - - ), - }, - { - name: 'X', - label: 'Latest news', - href: 'https://x.com/NxDevTools?utm_source=nx.dev', - icon: (props: any) => ( - - {/*X*/} - - - ), - }, - { - name: 'Youtube', - label: 'Youtube channel', - href: 'https://www.youtube.com/@NxDevtools?utm_source=nx.dev', - icon: (props: any) => ( - - {/*YouTube*/} - - - ), - }, - { - name: 'Newsletter', - label: 'The Newsletter', - href: 'https://go.nrwl.io/nx-newsletter?utm_source=nx.dev', - icon: (props: any) => ( - - - - ), - }, - ], - }; +const navigation = { + nx: [ + { name: 'Status', href: 'https://status.nx.app' }, + { name: 'Security', href: 'https://security.nx.app' }, + ], + nxCloud: [ + { name: 'App', href: 'https://cloud.nx.app' }, + { name: 'Docs', href: '/ci/intro/ci-with-nx' }, + { name: 'Pricing', href: '/pricing' }, + ], + solutions: [ + { name: 'Nx', href: 'https://nx.dev' }, + { name: 'Nx Cloud', href: '/nx-cloud' }, + { name: 'Nx Enterprise', href: '/enterprise' }, + ], + resources: [ + { name: 'Blog', href: '/blog' }, + { + name: 'Youtube', + href: 'https://youtube.com/@nxdevtools', + }, + { + name: 'Community', + href: '/community', + }, + { + name: 'Customers', + href: '/customers', + }, + ], + company: [ + { name: 'About us', href: '/company' }, + { name: 'Careers', href: '/careers' }, + { + name: 'Brands & Guidelines', + href: '/brands', + }, + { name: 'Contact us', href: '/contact' }, + ], + social: [ + { + name: 'Discord', + label: 'Community channel', + href: 'https://go.nx.dev/community', + icon: (props: any) => , + }, + { + name: 'GitHub', + label: 'Nx is open source, check the code on GitHub', + href: 'https://github.com/nrwl/nx?utm_source=nx.dev', + icon: (props: any) => ( + + {/*GitHub*/} + + + ), + }, + { + name: 'X', + label: 'Latest news', + href: 'https://x.com/NxDevTools?utm_source=nx.dev', + icon: (props: any) => ( + + {/*X*/} + + + ), + }, + { + name: 'Youtube', + label: 'Youtube channel', + href: 'https://www.youtube.com/@NxDevtools?utm_source=nx.dev', + icon: (props: any) => ( + + {/*YouTube*/} + + + ), + }, + { + name: 'Newsletter', + label: 'The Newsletter', + href: 'https://go.nrwl.io/nx-newsletter?utm_source=nx.dev', + icon: (props: any) => ( + + + + ), + }, + ], +}; + +export function Footer({ + className = '', +}: { className?: string } = {}): JSX.Element { return (