diff --git a/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx b/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx index 09f20c134..c1416270c 100644 --- a/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx +++ b/docs/tutorialkit.dev/src/content/docs/guides/creating-content.mdx @@ -3,6 +3,7 @@ title: Content creation description: 'Creating content in TutorialKit.' --- import { FileTree } from '@astrojs/starlight/components'; +import { Tabs, TabItem } from '@astrojs/starlight/components'; From an information architecture perspective, tutorial content is divided into **parts**, which are further divided into **chapters**, each consisting of **lessons**. @@ -36,6 +37,64 @@ This structure is reflected in the directory structure of your TutorialKit proje Navigate into one of these folders to see another folder that represents a **chapter**. Inside the chapter folder, you will find one or more **lesson** folders. +You can also omit parts or chapters such that you only have lessons or only lessons and parts. Here are a few examples: + + + + ```plaintext + - Lesson 1: Getting started + - Lesson 2: Adding pages + ``` + + + + + - src + - content + - tutorial + - getting-started + - _files/ + - _solution/ + - content.md + - adding-pages/ + - meta.md + - config.ts + - templates/ + + + + + + + ```plaintext + - Part 1: Introduction + - Lesson 1: What is Vite? + - Lesson 2: Installing + - … + - Part 2: Project structure + - … + ``` + + + + + - src + - content + - tutorial + - introduction/ + - what-is-vite/ + - _files/ + - _solution/ + - content.md + - installing/ + - project-structure/ + - meta.md + - config.ts + - templates/ + + + + ## A lesson content file Navigate to the `src/content/tutorial/1-basics/1-introduction/1-welcome` folder and open the `content.md` in your editor. You will see a file structured like this: diff --git a/e2e/configs/lessons-in-part.ts b/e2e/configs/lessons-in-part.ts new file mode 100644 index 000000000..be8a1b337 --- /dev/null +++ b/e2e/configs/lessons-in-part.ts @@ -0,0 +1,10 @@ +import tutorialkit from '@tutorialkit/astro'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + devToolbar: { enabled: false }, + server: { port: 4332 }, + outDir: './dist-lessons-in-part', + integrations: [tutorialkit()], + srcDir: './src-custom/lessons-in-part', +}); diff --git a/e2e/configs/lessons-in-root.ts b/e2e/configs/lessons-in-root.ts new file mode 100644 index 000000000..3885d68e2 --- /dev/null +++ b/e2e/configs/lessons-in-root.ts @@ -0,0 +1,10 @@ +import tutorialkit from '@tutorialkit/astro'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + devToolbar: { enabled: false }, + server: { port: 4331 }, + outDir: './dist-lessons-in-root', + integrations: [tutorialkit()], + srcDir: './src-custom/lessons-in-root', +}); diff --git a/e2e/package.json b/e2e/package.json index 5f4e5a297..d21b1326c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,6 +7,10 @@ "preview": "astro build && astro preview", "dev:override-components": "astro dev --config ./configs/override-components.ts", "preview:override-components": "astro build --config ./configs/override-components.ts && astro preview --config ./configs/override-components.ts", + "dev:lessons-in-root": "astro dev --config ./configs/lessons-in-root.ts", + "preview:lessons-in-root": "astro build --config ./configs/lessons-in-root.ts && astro preview --config ./configs/lessons-in-root.ts", + "dev:lessons-in-part": "astro dev --config ./configs/lessons-in-part.ts", + "preview:lessons-in-part": "astro build --config ./configs/lessons-in-part.ts && astro preview --config ./configs/lessons-in-part.ts", "test": "playwright test", "test:ui": "pnpm run test --ui" }, diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index a9654578e..2b7fcaf00 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,11 +1,21 @@ import { defineConfig } from '@playwright/test'; +const serverOptions = { + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + stderr: 'pipe', +} as const; + export default defineConfig({ projects: [ { name: 'Default', testMatch: 'test/*.test.ts', - testIgnore: 'test/*.override-components.test.ts', + testIgnore: [ + 'test/*.override-components.test.ts', + 'test/*.lessons-in-part.test.ts', + 'test/*.lessons-in-root.test.ts', + ], use: { baseURL: 'http://localhost:4329' }, }, { @@ -13,21 +23,37 @@ export default defineConfig({ testMatch: 'test/*.override-components.test.ts', use: { baseURL: 'http://localhost:4330' }, }, + { + name: 'Lessons in root', + testMatch: 'test/*.lessons-in-root.test.ts', + use: { baseURL: 'http://localhost:4331' }, + }, + { + name: 'Lessons in part', + testMatch: 'test/*.lessons-in-part.test.ts', + use: { baseURL: 'http://localhost:4332' }, + }, ], webServer: [ { command: 'pnpm preview', url: 'http://localhost:4329', - reuseExistingServer: !process.env.CI, - stdout: 'ignore', - stderr: 'pipe', + ...serverOptions, }, { command: 'pnpm preview:override-components', url: 'http://localhost:4330', - reuseExistingServer: !process.env.CI, - stdout: 'ignore', - stderr: 'pipe', + ...serverOptions, + }, + { + command: 'pnpm preview:lessons-in-root', + url: 'http://localhost:4331', + ...serverOptions, + }, + { + command: 'pnpm preview:lessons-in-part', + url: 'http://localhost:4332', + ...serverOptions, }, ], expect: { diff --git a/e2e/src-custom/lessons-in-part/content/config.ts b/e2e/src-custom/lessons-in-part/content/config.ts new file mode 100644 index 000000000..8e595c053 --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/config.ts @@ -0,0 +1,9 @@ +import { contentSchema } from '@tutorialkit/types'; +import { defineCollection } from 'astro:content'; + +const tutorial = defineCollection({ + type: 'content', + schema: contentSchema, +}); + +export const collections = { tutorial }; diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/meta.md b/e2e/src-custom/lessons-in-part/content/tutorial/meta.md new file mode 100644 index 000000000..29eef72cd --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/meta.md @@ -0,0 +1,5 @@ +--- +type: tutorial +mainCommand: '' +prepareCommands: [] +--- diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/part-one/lesson-1/content.md b/e2e/src-custom/lessons-in-part/content/tutorial/part-one/lesson-1/content.md new file mode 100644 index 000000000..23336e18b --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/part-one/lesson-1/content.md @@ -0,0 +1,8 @@ +--- +type: lesson +title: Lesson one +--- + +# Lessons in part test - Lesson one + +Lesson in part without chapter diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/part-one/lesson-2/content.md b/e2e/src-custom/lessons-in-part/content/tutorial/part-one/lesson-2/content.md new file mode 100644 index 000000000..8f01c46cf --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/part-one/lesson-2/content.md @@ -0,0 +1,8 @@ +--- +type: lesson +title: Lesson two +--- + +# Lessons in part test - Lesson two + +Lesson in part without chapter diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/part-one/meta.md b/e2e/src-custom/lessons-in-part/content/tutorial/part-one/meta.md new file mode 100644 index 000000000..26dd03157 --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/part-one/meta.md @@ -0,0 +1,4 @@ +--- +type: part +title: 'Part one' +--- diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/lesson-3/content.md b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/lesson-3/content.md new file mode 100644 index 000000000..d5b57bce8 --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/lesson-3/content.md @@ -0,0 +1,8 @@ +--- +type: lesson +title: Lesson three +--- + +# Lessons in part test - Lesson three + +Lesson in chapter diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/lesson-4/content.md b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/lesson-4/content.md new file mode 100644 index 000000000..39bc85308 --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/lesson-4/content.md @@ -0,0 +1,8 @@ +--- +type: lesson +title: Lesson four +--- + +# Lessons in part test - Lesson four + +Lesson in chapter diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/meta.md b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/meta.md new file mode 100644 index 000000000..f61b4d92a --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/chapter-one/meta.md @@ -0,0 +1,4 @@ +--- +type: chapter +title: 'Chapter one' +--- diff --git a/e2e/src-custom/lessons-in-part/content/tutorial/part-two/meta.md b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/meta.md new file mode 100644 index 000000000..47258e932 --- /dev/null +++ b/e2e/src-custom/lessons-in-part/content/tutorial/part-two/meta.md @@ -0,0 +1,4 @@ +--- +type: part +title: 'Part two' +--- diff --git a/e2e/src-custom/lessons-in-part/env.d.ts b/e2e/src-custom/lessons-in-part/env.d.ts new file mode 100644 index 000000000..d63e058a8 --- /dev/null +++ b/e2e/src-custom/lessons-in-part/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/e2e/src-custom/lessons-in-root/content/config.ts b/e2e/src-custom/lessons-in-root/content/config.ts new file mode 100644 index 000000000..8e595c053 --- /dev/null +++ b/e2e/src-custom/lessons-in-root/content/config.ts @@ -0,0 +1,9 @@ +import { contentSchema } from '@tutorialkit/types'; +import { defineCollection } from 'astro:content'; + +const tutorial = defineCollection({ + type: 'content', + schema: contentSchema, +}); + +export const collections = { tutorial }; diff --git a/e2e/src-custom/lessons-in-root/content/tutorial/lesson-one/content.md b/e2e/src-custom/lessons-in-root/content/tutorial/lesson-one/content.md new file mode 100644 index 000000000..f34459018 --- /dev/null +++ b/e2e/src-custom/lessons-in-root/content/tutorial/lesson-one/content.md @@ -0,0 +1,8 @@ +--- +type: lesson +title: Lesson one +--- + +# Lessons in root test - Lesson one + +Lesson in root without part diff --git a/e2e/src-custom/lessons-in-root/content/tutorial/lesson-two/content.md b/e2e/src-custom/lessons-in-root/content/tutorial/lesson-two/content.md new file mode 100644 index 000000000..9b876ca40 --- /dev/null +++ b/e2e/src-custom/lessons-in-root/content/tutorial/lesson-two/content.md @@ -0,0 +1,8 @@ +--- +type: lesson +title: Lesson two +--- + +# Lessons in root test - Lesson two + +Lesson in root without part diff --git a/e2e/src-custom/lessons-in-root/content/tutorial/meta.md b/e2e/src-custom/lessons-in-root/content/tutorial/meta.md new file mode 100644 index 000000000..29eef72cd --- /dev/null +++ b/e2e/src-custom/lessons-in-root/content/tutorial/meta.md @@ -0,0 +1,5 @@ +--- +type: tutorial +mainCommand: '' +prepareCommands: [] +--- diff --git a/e2e/src-custom/lessons-in-root/env.d.ts b/e2e/src-custom/lessons-in-root/env.d.ts new file mode 100644 index 000000000..d63e058a8 --- /dev/null +++ b/e2e/src-custom/lessons-in-root/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/e2e/test/navigation.lessons-in-part.test.ts b/e2e/test/navigation.lessons-in-part.test.ts new file mode 100644 index 000000000..2c19f2c45 --- /dev/null +++ b/e2e/test/navigation.lessons-in-part.test.ts @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; + +test('user can navigate between lessons using breadcrumbs', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { level: 1, name: 'Lessons in part test - Lesson one' })).toBeVisible(); + await expect(page.getByText('Lesson in part without chapter')).toBeVisible(); + + // navigation select can take a while to hydrate on page load, click until responsive + await expect(async () => { + const button = page.getByRole('button', { name: 'Part one / Lesson one' }); + await button.click(); + await expect(page.locator('[data-state="open"]', { has: button })).toBeVisible({ timeout: 50 }); + }).toPass(); + + const navigation = page.getByRole('navigation'); + await navigation.getByRole('region', { name: 'Part 1: Part one' }).getByRole('link', { name: 'Lesson two' }).click(); + + await expect(page.getByRole('heading', { level: 1, name: 'Lessons in part test - Lesson two' })).toBeVisible(); + await expect(page.getByText('Lesson in part without chapter')).toBeVisible(); + + await expect(async () => { + const button = page.getByRole('button', { name: 'Part one / Lesson two' }); + await button.click(); + await expect(page.locator('[data-state="open"]', { has: button })).toBeVisible({ timeout: 50 }); + }).toPass(); + + // expand part + await navigation.getByRole('button', { name: 'Part 2: Part two' }).click(); + + // expand chapter + await navigation + .getByRole('region', { name: 'Part 2: Part two' }) + .getByRole('button', { name: 'Chapter one' }) + .click(); + + // select lesson + await navigation.getByRole('region', { name: 'Chapter one' }).getByRole('link', { name: 'Lesson three' }).click(); + + await expect(page.getByRole('heading', { level: 1, name: 'Lessons in part test - Lesson three' })).toBeVisible(); + await expect(page.getByText('Lesson in chapter')).toBeVisible(); +}); + +test('user can navigate between lessons using nav bar links', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1, name: 'Lessons in part test - Lesson one' })).toBeVisible(); + await expect(page.getByText('Lesson in part without chapter')).toBeVisible(); + + await navigateToPage('Lesson two'); + await expect(page.getByText('Lesson in part without chapter')).toBeVisible(); + + await navigateToPage('Lesson three'); + await expect(page.getByText('Lesson in chapter')).toBeVisible(); + + await navigateToPage('Lesson four'); + await expect(page.getByText('Lesson in chapter')).toBeVisible(); + + async function navigateToPage(title: string) { + await page.getByRole('link', { name: title }).click(); + await expect(page.getByRole('heading', { level: 1, name: `Lessons in part test - ${title}` })).toBeVisible(); + } +}); diff --git a/e2e/test/navigation.lessons-in-root.test.ts b/e2e/test/navigation.lessons-in-root.test.ts new file mode 100644 index 000000000..80eda1884 --- /dev/null +++ b/e2e/test/navigation.lessons-in-root.test.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +test('user can navigate between lessons using breadcrumbs', async ({ page }) => { + await page.goto('/lesson-one'); + + await expect(page.getByRole('heading', { level: 1, name: 'Lessons in root test - Lesson one' })).toBeVisible(); + await expect(page.getByText('Lesson in root without part')).toBeVisible(); + + // navigation select can take a while to hydrate on page load, click until responsive + await expect(async () => { + const button = page.getByRole('button', { name: 'Lesson one' }); + await button.click(); + await expect(page.locator('[data-state="open"]', { has: button })).toBeVisible({ timeout: 50 }); + }).toPass(); + + await page.getByRole('navigation').getByRole('link', { name: 'Lesson two' }).click(); + + await expect(page.getByRole('heading', { level: 1, name: 'Lessons in root test - Lesson two' })).toBeVisible(); +}); diff --git a/packages/astro/src/default/pages/index.astro b/packages/astro/src/default/pages/index.astro index c7d970510..f832ed325 100644 --- a/packages/astro/src/default/pages/index.astro +++ b/packages/astro/src/default/pages/index.astro @@ -4,11 +4,13 @@ import { joinPaths } from '../utils/url'; const tutorial = await getTutorial(); -const part = tutorial.parts[tutorial.firstPartId!]; -const chapter = part.chapters[part?.firstChapterId!]; -const lesson = chapter.lessons[chapter?.firstLessonId!]; +const lesson = tutorial.lessons[0]; +const part = lesson.part && tutorial.parts[lesson.part.id]; +const chapter = lesson.chapter && part?.chapters[lesson.chapter.id]; -const redirect = joinPaths(import.meta.env.BASE_URL, `/${part.slug}/${chapter.slug}/${lesson.slug}`); +const slug = [part?.slug, chapter?.slug, lesson.slug].filter(Boolean).join('/'); + +const redirect = joinPaths(import.meta.env.BASE_URL, `/${slug}`); --- diff --git a/packages/astro/src/default/utils/__snapshots__/multiple-parts.json b/packages/astro/src/default/utils/__snapshots__/multiple-parts.json index 3ccc38cbf..42fa3bb17 100644 --- a/packages/astro/src/default/utils/__snapshots__/multiple-parts.json +++ b/packages/astro/src/default/utils/__snapshots__/multiple-parts.json @@ -16,49 +16,9 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-first": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-first", - "filepath": "1-part/1-chapter/1-first/content.md", - "order": 0, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "1-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-1-chapter-1-first-files.json", - [] - ], - "solution": [ - "1-part-1-chapter-1-first-solution.json", - [] - ], - "next": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - } - }, - "firstLessonId": "1-first" + "slug": "chapter-slug" } - }, - "firstChapterId": "1-chapter" + } }, "2-part": { "id": "2-part", @@ -76,53 +36,9 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-second": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-second", - "filepath": "2-part/2-chapter/1-second/content.md", - "order": 0, - "part": { - "id": "2-part", - "title": "Basics" - }, - "chapter": { - "id": "2-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "2-part-2-chapter-1-second-files.json", - [] - ], - "solution": [ - "2-part-2-chapter-1-second-solution.json", - [] - ], - "prev": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - }, - "next": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - } - }, - "firstLessonId": "1-second" + "slug": "chapter-slug" } - }, - "firstChapterId": "2-chapter" + } }, "3-part": { "id": "3-part", @@ -140,50 +56,123 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-third": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-third", - "filepath": "3-part/3-chapter/1-third/content.md", - "order": 0, - "part": { - "id": "3-part", - "title": "Basics" - }, - "chapter": { - "id": "3-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "3-part-3-chapter-1-third-files.json", - [] - ], - "solution": [ - "3-part-3-chapter-1-third-solution.json", - [] - ], - "prev": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - } - }, - "firstLessonId": "1-third" + "slug": "chapter-slug" } - }, - "firstChapterId": "3-chapter" + } } }, - "firstPartId": "1-part" + "lessons": [ + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-first", + "filepath": "1-part/1-chapter/1-first/content.md", + "order": 0, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-first-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-first-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-second", + "filepath": "2-part/2-chapter/1-second/content.md", + "order": 1, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "2-part-2-chapter-1-second-files.json", + [] + ], + "solution": [ + "2-part-2-chapter-1-second-solution.json", + [] + ], + "part": { + "id": "2-part", + "title": "Basics" + }, + "chapter": { + "id": "2-chapter", + "title": "The first chapter in part 1" + }, + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-third", + "filepath": "3-part/3-chapter/1-third/content.md", + "order": 2, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "3-part-3-chapter-1-third-files.json", + [] + ], + "solution": [ + "3-part-3-chapter-1-third-solution.json", + [] + ], + "part": { + "id": "3-part", + "title": "Basics" + }, + "chapter": { + "id": "3-chapter", + "title": "The first chapter in part 1" + }, + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + ] } \ No newline at end of file diff --git a/packages/astro/src/default/utils/__snapshots__/single-lesson-no-part.json b/packages/astro/src/default/utils/__snapshots__/single-lesson-no-part.json new file mode 100644 index 000000000..8455d92cc --- /dev/null +++ b/packages/astro/src/default/utils/__snapshots__/single-lesson-no-part.json @@ -0,0 +1,29 @@ +{ + "parts": {}, + "lessons": [ + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-lesson", + "filepath": "1-lesson/content.md", + "order": 0, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-lesson-files.json", + [] + ], + "solution": [ + "1-lesson-solution.json", + [] + ] + } + ] +} \ No newline at end of file diff --git a/packages/astro/src/default/utils/__snapshots__/single-part-and-lesson-no-chapter.json b/packages/astro/src/default/utils/__snapshots__/single-part-and-lesson-no-chapter.json new file mode 100644 index 000000000..447e765b3 --- /dev/null +++ b/packages/astro/src/default/utils/__snapshots__/single-part-and-lesson-no-chapter.json @@ -0,0 +1,44 @@ +{ + "parts": { + "1-part": { + "id": "1-part", + "order": 0, + "data": { + "type": "part", + "title": "Basics" + }, + "slug": "part-slug", + "chapters": {} + } + }, + "lessons": [ + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-lesson", + "filepath": "1-part/1-lesson/content.md", + "order": 0, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-lesson-files.json", + [] + ], + "solution": [ + "1-part-1-lesson-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + } + } + ] +} \ No newline at end of file diff --git a/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json index 6ce2dd637..bd70e0a18 100644 --- a/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json +++ b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json @@ -16,46 +16,43 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-lesson": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-lesson", - "filepath": "1-part/1-chapter/1-lesson/content.md", - "order": 0, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "1-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-1-chapter-1-lesson-files.json", - [] - ], - "solution": [ - "1-part-1-chapter-1-lesson-solution.json", - [] - ] - } - }, - "firstLessonId": "1-lesson" + "slug": "chapter-slug" } - }, - "firstChapterId": "1-chapter" + } } }, - "firstPartId": "1-part" + "lessons": [ + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-lesson", + "filepath": "1-part/1-chapter/1-lesson/content.md", + "order": 0, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-lesson-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-lesson-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + } + } + ] } \ No newline at end of file diff --git a/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json index 605f61555..68fc92ca5 100644 --- a/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json +++ b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json @@ -16,126 +16,123 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-first": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-first", - "filepath": "1-part/1-chapter/1-first/content.md", - "order": 0, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "1-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-1-chapter-1-first-files.json", - [] - ], - "solution": [ - "1-part-1-chapter-1-first-solution.json", - [] - ], - "next": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - }, - "2-second": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "2-second", - "filepath": "1-part/1-chapter/2-second/content.md", - "order": 1, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "1-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-1-chapter-2-second-files.json", - [] - ], - "solution": [ - "1-part-1-chapter-2-second-solution.json", - [] - ], - "prev": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - }, - "next": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - }, - "3-third": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "3-third", - "filepath": "1-part/1-chapter/3-third/content.md", - "order": 2, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "1-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-1-chapter-3-third-files.json", - [] - ], - "solution": [ - "1-part-1-chapter-3-third-solution.json", - [] - ], - "prev": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - } - }, - "firstLessonId": "1-first" + "slug": "chapter-slug" } - }, - "firstChapterId": "1-chapter" + } } }, - "firstPartId": "1-part" + "lessons": [ + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-first", + "filepath": "1-part/1-chapter/1-first/content.md", + "order": 0, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-first-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-first-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "2-second", + "filepath": "1-part/1-chapter/2-second/content.md", + "order": 1, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-2-second-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-2-second-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "3-third", + "filepath": "1-part/1-chapter/3-third/content.md", + "order": 2, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-3-third-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-3-third-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + ] } \ No newline at end of file diff --git a/packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json b/packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json index b6d2cf7b2..3504e10f6 100644 --- a/packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json +++ b/packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json @@ -16,46 +16,7 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-first": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-first", - "filepath": "1-part/1-chapter/1-first/content.md", - "order": 0, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "1-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-1-chapter-1-first-files.json", - [] - ], - "solution": [ - "1-part-1-chapter-1-first-solution.json", - [] - ], - "next": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - } - }, - "firstLessonId": "1-first" + "slug": "chapter-slug" }, "2-chapter": { "id": "2-chapter", @@ -64,50 +25,7 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-second": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-second", - "filepath": "1-part/2-chapter/1-second/content.md", - "order": 0, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "2-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-2-chapter-1-second-files.json", - [] - ], - "solution": [ - "1-part-2-chapter-1-second-solution.json", - [] - ], - "prev": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - }, - "next": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - } - }, - "firstLessonId": "1-second" + "slug": "chapter-slug" }, "3-chapter": { "id": "3-chapter", @@ -116,50 +34,123 @@ "title": "The first chapter in part 1", "type": "chapter" }, - "slug": "chapter-slug", - "lessons": { - "1-third": { - "data": { - "type": "lesson", - "title": "Welcome to TutorialKit", - "template": "default", - "i18n": { - "mocked": "default localization" - }, - "openInStackBlitz": true - }, - "id": "1-third", - "filepath": "1-part/3-chapter/1-third/content.md", - "order": 0, - "part": { - "id": "1-part", - "title": "Basics" - }, - "chapter": { - "id": "3-chapter", - "title": "The first chapter in part 1" - }, - "Markdown": "Markdown for tutorial", - "slug": "lesson-slug", - "files": [ - "1-part-3-chapter-1-third-files.json", - [] - ], - "solution": [ - "1-part-3-chapter-1-third-solution.json", - [] - ], - "prev": { - "title": "Welcome to TutorialKit", - "href": "/part-slug/chapter-slug/lesson-slug" - } - } - }, - "firstLessonId": "1-third" + "slug": "chapter-slug" } - }, - "firstChapterId": "1-chapter" + } } }, - "firstPartId": "1-part" + "lessons": [ + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-first", + "filepath": "1-part/1-chapter/1-first/content.md", + "order": 0, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-first-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-first-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-second", + "filepath": "1-part/2-chapter/1-second/content.md", + "order": 1, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-2-chapter-1-second-files.json", + [] + ], + "solution": [ + "1-part-2-chapter-1-second-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "2-chapter", + "title": "The first chapter in part 1" + }, + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-third", + "filepath": "1-part/3-chapter/1-third/content.md", + "order": 2, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-3-chapter-1-third-files.json", + [] + ], + "solution": [ + "1-part-3-chapter-1-third-solution.json", + [] + ], + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "3-chapter", + "title": "The first chapter in part 1" + }, + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + ] } \ No newline at end of file diff --git a/packages/astro/src/default/utils/content.spec.ts b/packages/astro/src/default/utils/content.spec.ts index 874300550..679db449d 100644 --- a/packages/astro/src/default/utils/content.spec.ts +++ b/packages/astro/src/default/utils/content.spec.ts @@ -45,7 +45,7 @@ test('single part, chapter and multiple lessons', async (ctx) => { const collection = await getTutorial(); - const lessons = collection.parts['1-part'].chapters['1-chapter'].lessons; + const lessons = collection.lessons; expect(Object.keys(lessons)).toHaveLength(3); await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); @@ -119,11 +119,86 @@ test('lessons with identical names in different chapters', async () => { ]); const collection = await getTutorial(); - const chapters = collection.parts['1-part'].chapters; + const lessons = collection.lessons; + + // verify that lesson.id is not used to define what makes a lesson unique (part.id + chapter.id too) + expect(lessons).toHaveLength(2); + expect(lessons[0].id).toBe('identical-lesson-name'); + expect(lessons[1].id).toBe('identical-lesson-name'); + + expect(lessons[0].data.focus).toBe('/first.js'); + expect(lessons[1].data.focus).toBe('/second.js'); + + expect(lessons[0].chapter?.id).toBe('1-chapter'); + expect(lessons[1].chapter?.id).toBe('2-chapter'); + + expect(lessons[0].part?.id).toBe('1-part'); + expect(lessons[1].part?.id).toBe('1-part'); +}); + +test('lessons with identical names in mixed hierarchy', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '2-part/meta.md', ...part }, + { id: '2-part/2-chapter/meta.md', ...chapter }, + + { id: '1-part/identical-lesson-name/content.md', ...lesson }, + { id: '1-part/2nd-identical-lesson-name/content.md', ...lesson }, + + { id: '2-part/2-chapter/identical-lesson-name/content.md', ...lesson }, + { id: '2-part/2-chapter/2nd-identical-lesson-name/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + const lessons = collection.lessons; // verify that lesson.id is not used to define what makes a lesson unique (part.id + chapter.id too) - expect(chapters['1-chapter'].lessons['identical-lesson-name']).toBeDefined(); - expect(chapters['2-chapter'].lessons['identical-lesson-name']).toBeDefined(); + expect(lessons).toHaveLength(4); + expect(lessons[0].id).toBe('identical-lesson-name'); + expect(lessons[1].id).toBe('2nd-identical-lesson-name'); + expect(lessons[2].id).toBe('identical-lesson-name'); + expect(lessons[3].id).toBe('2nd-identical-lesson-name'); + + expect(lessons[0].chapter?.id).toBe(undefined); + expect(lessons[1].chapter?.id).toBe(undefined); + expect(lessons[2].chapter?.id).toBe('2-chapter'); + expect(lessons[3].chapter?.id).toBe('2-chapter'); + + expect(lessons[0].part?.id).toBe('1-part'); + expect(lessons[1].part?.id).toBe('1-part'); + expect(lessons[2].part?.id).toBe('2-part'); + expect(lessons[3].part?.id).toBe('2-part'); +}); + +test('single part and lesson, no chapter', async (ctx) => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-lesson/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + + const parts = Object.keys(collection.parts); + expect(parts).toHaveLength(1); + expect(Object.keys(collection.parts[parts[0]].chapters)).toHaveLength(0); + expect(collection.lessons).toHaveLength(1); + + await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); +}); + +test('single lesson, no part', async (ctx) => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-lesson/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + expect(Object.keys(collection.parts)).toHaveLength(0); + expect(collection.lessons).toHaveLength(1); + + await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); }); describe('metadata inheriting', () => { @@ -152,7 +227,7 @@ describe('metadata inheriting', () => { ]); const collection = await getTutorial(); - const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; + const { data: lessonData } = collection.lessons[0]; expect(lessonData).toStrictEqual({ ...data, type: 'lesson', title: lesson.data.title }); }); @@ -197,7 +272,7 @@ describe('metadata inheriting', () => { ]); const collection = await getTutorial(); - const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; + const { data: lessonData } = collection.lessons[0]; expect(lessonData.editPageLink).toBe('edit link from tutorial'); expect(lessonData.focus).toBe('focus from part'); @@ -339,14 +414,19 @@ describe('ordering', () => { ]); const collection = await getTutorial(); - const lessons = collection.parts['1-part'].chapters['1-chapter'].lessons; + const lessons = collection.lessons; + + expect(lessons[0].order).toBe(0); + expect(lessons[0].id).toBe('1-lesson'); - expect(lessons['1-lesson'].order).toBe(0); - expect(lessons['2-lesson'].order).toBe(1); - expect(lessons['3-lesson'].order).toBe(2); + expect(lessons[1].order).toBe(1); + expect(lessons[1].id).toBe('2-lesson'); + + expect(lessons[2].order).toBe(2); + expect(lessons[2].id).toBe('3-lesson'); }); - test('lessons are ordered by metadata', async () => { + test("lessons are ordered by chapter's metadata", async () => { getCollection.mockReturnValueOnce([ { id: 'meta.md', ...tutorial }, { id: '1-part/meta.md', ...part }, @@ -364,11 +444,73 @@ describe('ordering', () => { ]); const collection = await getTutorial(); - const lessons = collection.parts['1-part'].chapters['1-chapter'].lessons; + const lessons = collection.lessons; + + expect(lessons[0].order).toBe(0); + expect(lessons[0].id).toBe('3-lesson'); + + expect(lessons[1].order).toBe(1); + expect(lessons[1].id).toBe('1-lesson'); + + expect(lessons[2].order).toBe(2); + expect(lessons[2].id).toBe('2-lesson'); + }); + + test("lessons are ordered by part's metadata", async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { + id: '1-part/meta.md', + ...part, + data: { + ...part.data, + lessons: ['3-lesson', '1-lesson', '2-lesson'], + }, + }, + { id: '1-part/2-lesson/meta.md', ...lesson }, + { id: '1-part/3-lesson/meta.md', ...lesson }, + { id: '1-part/1-lesson/meta.md', ...lesson }, + ]); + + const collection = await getTutorial(); + const lessons = collection.lessons; + + expect(lessons[0].order).toBe(0); + expect(lessons[0].id).toBe('3-lesson'); + + expect(lessons[1].order).toBe(1); + expect(lessons[1].id).toBe('1-lesson'); + + expect(lessons[2].order).toBe(2); + expect(lessons[2].id).toBe('2-lesson'); + }); + + test("lessons are ordered by tutorial's metadata", async () => { + getCollection.mockReturnValueOnce([ + { + id: 'meta.md', + ...tutorial, + data: { + ...tutorial.data, + lessons: ['3-lesson', '1-lesson', '2-lesson'], + }, + }, + { id: '2-lesson/meta.md', ...lesson }, + { id: '3-lesson/meta.md', ...lesson }, + { id: '1-lesson/meta.md', ...lesson }, + ]); + + const collection = await getTutorial(); + const lessons = collection.lessons; - expect(lessons['3-lesson'].order).toBe(0); - expect(lessons['1-lesson'].order).toBe(1); - expect(lessons['2-lesson'].order).toBe(2); + expect(lessons[0].order).toBe(0); + expect(lessons[0].id).toBe('3-lesson'); + + expect(lessons[1].order).toBe(1); + expect(lessons[1].id).toBe('1-lesson'); + + expect(lessons[2].order).toBe(2); + expect(lessons[2].id).toBe('2-lesson'); }); test('lessons not mention in order are excluded ', async () => { @@ -388,11 +530,11 @@ describe('ordering', () => { ]); const collection = await getTutorial(); - const lessons = collection.parts['1-part'].chapters['1-chapter'].lessons; + const lessons = collection.lessons; - expect(Object.keys(lessons)).toHaveLength(2); - expect(lessons['1-lesson']).toBeDefined(); - expect(lessons['2-lesson']).toBeDefined(); + expect(lessons).toHaveLength(2); + expect(lessons[0].id).toBe('2-lesson'); + expect(lessons[1].id).toBe('1-lesson'); expect(vi.mocked(logger.warn).mock.calls[0][0]).toMatchInlineSnapshot( `"An order was specified for chapter '1-chapter' but lesson 'excluded-lesson' is not included, so it won't be visible."`, @@ -413,7 +555,7 @@ describe('missing parts', () => { ); }); - test('throws when part not found', async () => { + test('throws when part not found for chapter', async () => { getCollection.mockReturnValueOnce([ { id: 'meta.md', ...tutorial }, { id: '2-part/meta.md', ...part }, @@ -424,6 +566,15 @@ describe('missing parts', () => { await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find part '1-part']`); }); + test('throws when part not found for lesson', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/1-first/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find part '1-part']`); + }); + test('throws when chapter not found', async () => { getCollection.mockReturnValueOnce([ { id: 'meta.md', ...tutorial }, @@ -436,6 +587,33 @@ describe('missing parts', () => { }); }); +describe('mixed hierarchy', () => { + test('throws when tutorial has parts and lessons in same level', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-lesson/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot mix lessons and parts in a tutorial. Either remove the parts or move root level lessons into a part.]`, + ); + }); + + test('throws when a part has chapters and lessons in same level', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-lesson/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot mix lessons and chapters in a part. Either remove the chapter from 1-part or move the lessons into a chapter.]`, + ); + }); +}); + const tutorial = { slug: 'tutorial-slug', body: 'Hello world', diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts index b1665ba5b..7567b40c8 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -1,5 +1,14 @@ import path from 'node:path'; -import type { ChapterSchema, Lesson, LessonSchema, PartSchema, Tutorial, TutorialSchema } from '@tutorialkit/types'; +import type { + Chapter, + ChapterSchema, + Lesson, + LessonSchema, + Part, + PartSchema, + Tutorial, + TutorialSchema, +} from '@tutorialkit/types'; import { interpolateString, DEFAULT_LOCALIZATION } from '@tutorialkit/types'; import { getCollection } from 'astro:content'; import { getFilesRefList } from './content/files-ref'; @@ -10,18 +19,139 @@ import { joinPaths } from './url'; export async function getTutorial(): Promise { const collection = sortCollection(await getCollection('tutorial')); - const _tutorial: Tutorial = { + const { tutorial, tutorialMetaData } = await parseCollection(collection); + assertTutorialStructure(tutorial); + sortTutorialLessons(tutorial, tutorialMetaData); + + // find orphans discard them and print warnings + for (const partId in tutorial.parts) { + const part = tutorial.parts[partId]; + + if (part.order === -1) { + delete tutorial.parts[partId]; + logger.warn( + `An order was specified for the parts of the tutorial but '${partId}' is not included so it won't be visible.`, + ); + continue; + } + + for (const chapterId in part.chapters) { + const chapter = part.chapters[chapterId]; + + if (chapter.order === -1) { + delete part.chapters[chapterId]; + logger.warn( + `An order was specified for part '${partId}' but chapter '${chapterId}' is not included, so it won't be visible.`, + ); + continue; + } + + const chapterLessons = tutorial.lessons.filter((l) => l.chapter?.id === chapterId && l.part?.id === partId); + + for (const lesson of chapterLessons) { + if (lesson.order === -1) { + logger.warn( + `An order was specified for chapter '${chapterId}' but lesson '${lesson.id}' is not included, so it won't be visible.`, + ); + continue; + } + } + } + } + + // removed orphaned lessons + tutorial.lessons = tutorial.lessons.filter((lesson) => lesson.order > -1); + + const baseURL = import.meta.env.BASE_URL; + + // now we link all lessons together and apply metadata inheritance + for (const [i, lesson] of tutorial.lessons.entries()) { + const prevLesson = i > 0 ? tutorial.lessons.at(i - 1) : undefined; + const nextLesson = tutorial.lessons.at(i + 1); + + // order for metadata: lesson <- chapter (optional) <- part (optional) <- tutorial + const sources: (Lesson['data'] | Chapter['data'] | Part['data'] | TutorialSchema)[] = [lesson.data]; + + if (lesson.part && lesson.chapter) { + sources.push(tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].data); + } + + if (lesson.part) { + sources.push(tutorial.parts[lesson.part.id].data); + } + + sources.push(tutorialMetaData); + + lesson.data = { + ...lesson.data, + ...squash(sources, [ + 'mainCommand', + 'prepareCommands', + 'previews', + 'autoReload', + 'template', + 'terminal', + 'editor', + 'focus', + 'i18n', + 'meta', + 'editPageLink', + 'openInStackBlitz', + 'filesystem', + ]), + }; + + if (prevLesson) { + const partSlug = prevLesson.part && tutorial.parts[prevLesson.part.id].slug; + const chapterSlug = + prevLesson.part && + prevLesson.chapter && + tutorial.parts[prevLesson.part.id].chapters[prevLesson.chapter.id].slug; + + const slug = [partSlug, chapterSlug, prevLesson.slug].filter(Boolean).join('/'); + + lesson.prev = { + title: prevLesson.data.title, + href: joinPaths(baseURL, `/${slug}`), + }; + } + + if (nextLesson) { + const partSlug = nextLesson.part && tutorial.parts[nextLesson.part.id].slug; + const chapterSlug = + nextLesson.part && + nextLesson.chapter && + tutorial.parts[nextLesson.part.id].chapters[nextLesson.chapter.id].slug; + + const slug = [partSlug, chapterSlug, nextLesson.slug].filter(Boolean).join('/'); + + lesson.next = { + title: nextLesson.data.title, + href: joinPaths(baseURL, `/${slug}`), + }; + } + + if (lesson.data.editPageLink && typeof lesson.data.editPageLink === 'string') { + lesson.editPageLink = interpolateString(lesson.data.editPageLink, { path: lesson.filepath }); + } + } + + return tutorial; +} + +async function parseCollection(collection: CollectionEntryTutorial[]) { + const tutorial: Tutorial = { parts: {}, + lessons: [], }; let tutorialMetaData: TutorialSchema | undefined; - let lessons: Lesson[] = []; for (const entry of collection) { const { id, data } = entry; const { type } = data; - const [partId, chapterId, lessonId] = id.split('/'); + const { partId, chapterId, lessonId } = resolveIds(id, type); if (type === 'tutorial') { tutorialMetaData = data; @@ -31,9 +161,13 @@ export async function getTutorial(): Promise { tutorialMetaData.i18n = Object.assign({ ...DEFAULT_LOCALIZATION }, tutorialMetaData.i18n); tutorialMetaData.openInStackBlitz ??= true; - _tutorial.logoLink = data.logoLink; + tutorial.logoLink = data.logoLink; } else if (type === 'part') { - _tutorial.parts[partId] = { + if (!partId) { + throw new Error('Part missing id'); + } + + tutorial.parts[partId] = { id: partId, order: -1, data, @@ -41,24 +175,23 @@ export async function getTutorial(): Promise { chapters: {}, }; } else if (type === 'chapter') { - if (!_tutorial.parts[partId]) { + if (!chapterId || !partId) { + throw new Error(`Chapter missing ids: [${partId || null}, ${chapterId || null}]`); + } + + if (!tutorial.parts[partId]) { throw new Error(`Could not find part '${partId}'`); } - _tutorial.parts[partId].chapters[chapterId] = { + tutorial.parts[partId].chapters[chapterId] = { id: chapterId, order: -1, data, slug: getSlug(entry), - lessons: {}, }; } else if (type === 'lesson') { - if (!_tutorial.parts[partId]) { - throw new Error(`Could not find part '${partId}'`); - } - - if (!_tutorial.parts[partId].chapters[chapterId]) { - throw new Error(`Could not find chapter '${chapterId}'`); + if (!lessonId) { + throw new Error('Lesson missing id'); } const { Content } = await entry.render(); @@ -75,23 +208,35 @@ export async function getTutorial(): Promise { id: lessonId, filepath: id, order: -1, - part: { - id: partId, - title: _tutorial.parts[partId].data.title, - }, - chapter: { - id: chapterId, - title: _tutorial.parts[partId].chapters[chapterId].data.title, - }, Markdown: Content, slug: getSlug(entry), files, solution, }; - lessons.push(lesson); + if (partId) { + if (!tutorial.parts[partId]) { + throw new Error(`Could not find part '${partId}'`); + } + + lesson.part = { + id: partId, + title: tutorial.parts[partId].data.title, + }; + } - _tutorial.parts[partId].chapters[chapterId].lessons[lessonId] = lesson; + if (partId && chapterId) { + if (!tutorial.parts[partId].chapters[chapterId]) { + throw new Error(`Could not find chapter '${chapterId}'`); + } + + lesson.chapter = { + id: chapterId, + title: tutorial.parts[partId].chapters[chapterId].data.title, + }; + } + + tutorial.lessons.push(lesson); } } @@ -99,226 +244,190 @@ export async function getTutorial(): Promise { throw new Error(`Could not find tutorial 'meta.md' file`); } - // let's now compute the order for everything - const partsOrder = getOrder(tutorialMetaData.parts, _tutorial.parts); - - for (let p = 0; p < partsOrder.length; ++p) { - const partId = partsOrder[p]; - const part = _tutorial.parts[partId]; + return { tutorial, tutorialMetaData }; +} - if (!part) { - logger.warn(`Could not find '${partId}', it won't be part of the tutorial.`); - continue; - } +function getOrder( + order: string[] | undefined, + fallbackSourceForOrder: Record | Lesson['id'][], +): string[] { + if (order) { + return order; + } - if (!_tutorial.firstPartId) { - _tutorial.firstPartId = partId; - } + const keys = Array.isArray(fallbackSourceForOrder) + ? [...fallbackSourceForOrder] + : Object.keys(fallbackSourceForOrder); - part.order = p; + // default to an order based on having each folder prefixed by their order: `1-foo`, `2-bar`, etc. + return keys.sort((a, b) => { + const numA = parseInt(a, 10); + const numB = parseInt(b, 10); - const chapterOrder = getOrder(part.data.chapters, part.chapters); + return numA - numB; + }); +} - for (let c = 0; c < chapterOrder.length; ++c) { - const chapterId = chapterOrder[c]; - const chapter = part.chapters[chapterId]; +function sortCollection(collection: CollectionEntryTutorial[]) { + return collection.sort((a, b) => { + const depthA = a.id.split('/').length; + const depthB = b.id.split('/').length; - if (!chapter) { - logger.warn(`Could not find '${chapterId}', it won't be part of the part '${partId}'.`); - continue; - } + return depthA - depthB; + }); +} - if (!part.firstChapterId) { - part.firstChapterId = chapterId; - } +function getSlug(entry: CollectionEntryTutorial) { + let slug: string = entry.slug; - chapter.order = c; + if (entry.slug.includes('/')) { + const parts = entry.slug.split('/'); + const _slug = parts.at(-2); - const lessonOrder = getOrder(chapter.data.lessons, chapter.lessons); + if (!_slug) { + throw new Error('Invalid slug'); + } - for (let l = 0; l < lessonOrder.length; ++l) { - const lessonId = lessonOrder[l]; - const lesson = chapter.lessons[lessonId]; + slug = _slug; + } - if (!lesson) { - logger.warn(`Could not find '${lessonId}', it won't be part of the chapter '${chapterId}'.`); - continue; - } + return slug; +} - if (!chapter.firstLessonId) { - chapter.firstLessonId = lessonId; - } +function resolveIds( + id: string, + type: CollectionEntryTutorial['data']['type'], +): { partId?: string; chapterId?: string; lessonId?: string } { + const parts = id.split('/'); - lesson.order = l; - } - } + if (type === 'tutorial') { + return {}; } - // removed orphaned lessons - lessons = lessons.filter( - (lesson) => - lesson.order !== -1 && - _tutorial.parts[lesson.part.id].order !== -1 && - _tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].order !== -1, - ); - - // find orphans discard them and print warnings - for (const partId in _tutorial.parts) { - const part = _tutorial.parts[partId]; + if (type === 'part') { + return { + partId: parts[0], + }; + } - if (part.order === -1) { - delete _tutorial.parts[partId]; - logger.warn( - `An order was specified for the parts of the tutorial but '${partId}' is not included so it won't be visible.`, - ); - continue; - } + if (type === 'chapter') { + return { + partId: parts[0], + chapterId: parts[1], + }; + } - for (const chapterId in part.chapters) { - const chapter = part.chapters[chapterId]; + /** + * Supported schemes for lessons are are: + * - 'lesson-id/content.md' + * - 'part-id/lesson-id/content.md' + * - 'part-id/chapter-id/lesson-id/content.md' + */ + if (parts.length === 2) { + return { + lessonId: parts[0], + }; + } - if (chapter.order === -1) { - delete part.chapters[chapterId]; - logger.warn( - `An order was specified for part '${partId}' but chapter '${chapterId}' is not included, so it won't be visible.`, - ); - continue; - } + if (parts.length === 3) { + return { + partId: parts[0], + lessonId: parts[1], + }; + } - for (const lessonId in chapter.lessons) { - const lesson = chapter.lessons[lessonId]; + return { + partId: parts[0], + chapterId: parts[1], + lessonId: parts[2], + }; +} - if (lesson.order === -1) { - delete chapter.lessons[lessonId]; - logger.warn( - `An order was specified for chapter '${chapterId}' but lesson '${lessonId}' is not included, so it won't be visible.`, - ); - continue; - } - } - } +function assertTutorialStructure(tutorial: Tutorial) { + // verify that parts and lessons are not mixed in tutorial + if (Object.keys(tutorial.parts).length !== 0 && tutorial.lessons.some((lesson) => !lesson.part)) { + throw new Error( + 'Cannot mix lessons and parts in a tutorial. Either remove the parts or move root level lessons into a part.', + ); } - // sort lessons - lessons.sort((a, b) => { - const partsA = [ - _tutorial.parts[a.part.id].order, - _tutorial.parts[a.part.id].chapters[a.chapter.id].order, - a.order, - ] as const; - const partsB = [ - _tutorial.parts[b.part.id].order, - _tutorial.parts[b.part.id].chapters[b.chapter.id].order, - b.order, - ] as const; - - for (let i = 0; i < partsA.length; i++) { - if (partsA[i] !== partsB[i]) { - return partsA[i] - partsB[i]; - } + // verify that chapters and lessons are not mixed in a single part + for (const part of Object.values(tutorial.parts)) { + if (Object.keys(part.chapters).length === 0) { + continue; } - return 0; - }); - - const baseURL = import.meta.env.BASE_URL; - - // now we link all lessons together - for (const [i, lesson] of lessons.entries()) { - const prevLesson = i > 0 ? lessons.at(i - 1) : undefined; - const nextLesson = lessons.at(i + 1); - - const partMetadata = _tutorial.parts[lesson.part.id].data; - const chapterMetadata = _tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].data; + if (tutorial.lessons.some((lesson) => lesson.part?.id === part.id && !lesson.chapter)) { + throw new Error( + `Cannot mix lessons and chapters in a part. Either remove the chapter from ${part.id} or move the lessons into a chapter.`, + ); + } + } +} - lesson.data = { - ...lesson.data, - ...squash( - [lesson.data, chapterMetadata, partMetadata, tutorialMetaData], - [ - 'mainCommand', - 'prepareCommands', - 'previews', - 'autoReload', - 'template', - 'terminal', - 'editor', - 'focus', - 'i18n', - 'meta', - 'editPageLink', - 'openInStackBlitz', - 'filesystem', - ], - ), - }; +function sortTutorialLessons(tutorial: Tutorial, metadata: TutorialSchema) { + const lessonIds = tutorial.lessons.map((lesson) => lesson.id); - if (prevLesson) { - const partSlug = _tutorial.parts[prevLesson.part.id].slug; - const chapterSlug = _tutorial.parts[prevLesson.part.id].chapters[prevLesson.chapter.id].slug; + // lesson ID alone does not make a lesson unique - combination of lessonId + chapterId + partId does + const lessonOrder: { lessonId: Lesson['id']; chapterId?: Chapter['id']; partId?: Part['id'] }[] = []; - lesson.prev = { - title: prevLesson.data.title, - href: joinPaths(baseURL, `/${partSlug}/${chapterSlug}/${prevLesson.slug}`), - }; - } + const lessonsInRoot = Object.keys(tutorial.parts).length === 0; - if (nextLesson) { - const partSlug = _tutorial.parts[nextLesson.part.id].slug; - const chapterSlug = _tutorial.parts[nextLesson.part.id].chapters[nextLesson.chapter.id].slug; + // if lessons in root, sort by tutorial.lessons and metadata.lessons + if (lessonsInRoot) { + lessonOrder.push(...getOrder(metadata.lessons, lessonIds).map((lessonId) => ({ lessonId }))); + } - lesson.next = { - title: nextLesson.data.title, - href: joinPaths(baseURL, `/${partSlug}/${chapterSlug}/${nextLesson.slug}`), - }; - } + // if no lessons in root, sort by parts and their possible chapters + if (!lessonsInRoot) { + for (const [partOrder, partId] of getOrder(metadata.parts, tutorial.parts).entries()) { + const part = tutorial.parts[partId]; - if (lesson.data.editPageLink && typeof lesson.data.editPageLink === 'string') { - lesson.editPageLink = interpolateString(lesson.data.editPageLink, { path: lesson.filepath }); - } - } + if (!part) { + continue; + } - return _tutorial; -} + part.order = partOrder; -function getOrder(order: string[] | undefined, fallbackSourceForOrder: Record): string[] { - if (order) { - return order; - } + const partLessons = tutorial.lessons + .filter((lesson) => lesson.chapter == null && lesson.part?.id === partId) + .map((lesson) => lesson.id); - // default to an order based on having each folder prefixed by their order: `1-foo`, `2-bar`, etc. - return Object.keys(fallbackSourceForOrder).sort((a, b) => { - const numA = parseInt(a, 10); - const numB = parseInt(b, 10); + // all lessons are in part, no chapters + if (partLessons.length) { + lessonOrder.push(...getOrder(part.data.lessons, partLessons).map((lessonId) => ({ lessonId, partId }))); + continue; + } - return numA - numB; - }); -} + // lessons in chapters + for (const [chapterOrder, chapterId] of getOrder(part.data.chapters, part.chapters).entries()) { + const chapter = part.chapters[chapterId]; -function sortCollection(collection: CollectionEntryTutorial[]) { - return collection.sort((a, b) => { - const depthA = a.id.split('/').length; - const depthB = b.id.split('/').length; + if (!chapter) { + continue; + } - return depthA - depthB; - }); -} + chapter.order = chapterOrder; -function getSlug(entry: CollectionEntryTutorial) { - let slug: string = entry.slug; + const chapterLessons = tutorial.lessons + .filter((lesson) => lesson.chapter?.id === chapter.id && lesson.part?.id === partId) + .map((lesson) => lesson.id); - if (entry.slug.includes('/')) { - const parts = entry.slug.split('/'); - const _slug = parts.at(-2); + const chapterLessonOrder = getOrder(chapter.data.lessons, chapterLessons); - if (!_slug) { - throw new Error('Invalid slug'); + lessonOrder.push(...chapterLessonOrder.map((lessonId) => ({ lessonId, partId, chapterId }))); + } } + } - slug = _slug; + // finally apply overall order for lessons + for (const lesson of tutorial.lessons) { + lesson.order = lessonOrder.findIndex( + (l) => l.lessonId === lesson.id && l.chapterId === lesson.chapter?.id && l.partId === lesson.part?.id, + ); } - return slug; + tutorial.lessons.sort((a, b) => a.order - b.order); } export interface CollectionEntryTutorial { diff --git a/packages/astro/src/default/utils/nav.ts b/packages/astro/src/default/utils/nav.ts index ec100a2f4..76a736ad0 100644 --- a/packages/astro/src/default/utils/nav.ts +++ b/packages/astro/src/default/utils/nav.ts @@ -1,30 +1,65 @@ -import type { Tutorial, NavList } from '@tutorialkit/types'; +import type { Tutorial, NavList, Part, Chapter } from '@tutorialkit/types'; import { joinPaths } from './url'; +type NavItem = Required>; + export function generateNavigationList(tutorial: Tutorial, baseURL: string): NavList { - return objectToSortedArray(tutorial.parts).map((part) => { - return { - id: part.id, - title: part.data.title, - sections: objectToSortedArray(part.chapters).map((chapter) => { - return { - id: chapter.id, - title: chapter.data.title, - sections: objectToSortedArray(chapter.lessons).map((lesson) => { - return { - id: lesson.id, - title: lesson.data.title, - href: joinPaths(baseURL, `/${part.slug}/${chapter.slug}/${lesson.slug}`), - }; - }), - }; - }), + const list: NavList = []; + + // caches for higher level items + const chapterItems = new Map(); + const partItems = new Map(); + + for (const lesson of tutorial.lessons) { + const part = lesson.part && tutorial.parts[lesson.part.id]; + const chapter = lesson.chapter && part && part.chapters[lesson.chapter.id]; + + let partItem = partItems.get(part?.id); + let chapterItem = chapterItems.get(chapter?.id); + + if (part && !partItem) { + partItem = { + id: part.id, + title: part.data.title, + type: 'part', + sections: [], + }; + list.push(partItem); + partItems.set(part.id, partItem); + } + + if (chapter && !chapterItem) { + if (!partItem) { + throw new Error('Failed to resolve part'); + } + + chapterItem = { + id: chapter.id, + title: chapter.data.title, + type: 'chapter', + sections: [], + }; + chapterItems.set(chapter.id, chapterItem); + partItem.sections.push(chapterItem); + } + + const slug = [part?.slug, chapter?.slug, lesson.slug].filter(Boolean).join('/'); + + const lessonItem: NavList[number] = { + id: lesson.id, + title: lesson.data.title, + type: 'lesson', + href: joinPaths(baseURL, `/${slug}`), }; - }); -} -function objectToSortedArray>(object: T): Array { - return Object.keys(object) - .map((key) => object[key] as T[keyof T]) - .sort((a, b) => a.order - b.order); + if (chapterItem) { + chapterItem.sections.push(lessonItem); + } else if (partItem) { + partItem.sections.push(lessonItem); + } else { + list.push(lessonItem); + } + } + + return list; } diff --git a/packages/astro/src/default/utils/routes.ts b/packages/astro/src/default/utils/routes.ts index b1ad833ea..01ff92acd 100644 --- a/packages/astro/src/default/utils/routes.ts +++ b/packages/astro/src/default/utils/routes.ts @@ -8,29 +8,26 @@ export async function generateStaticRoutes() { const tutorial = await getTutorial(); const routes = []; + const lessons = Object.values(tutorial.lessons); - const parts = Object.values(tutorial.parts); + for (const lesson of lessons) { + const part = lesson.part && tutorial.parts[lesson.part.id]; + const chapter = lesson.chapter && part?.chapters[lesson.chapter.id]; - for (const part of parts) { - const chapters = Object.values(part.chapters); + const slug = [part?.slug, chapter?.slug, lesson.slug].filter(Boolean).join('/'); + const title = [lesson.part?.title, lesson.chapter?.title, lesson.data.title].filter(Boolean).join(' / '); - for (const chapter of chapters) { - const lessons = Object.values(chapter.lessons); - - for (const lesson of lessons) { - routes.push({ - params: { - slug: `/${part.slug}/${chapter.slug}/${lesson.slug}`, - }, - props: { - logoLink: tutorial.logoLink, - navList: generateNavigationList(tutorial, import.meta.env.BASE_URL), - title: `${part.data.title} / ${chapter.data.title} / ${lesson.data.title}`, - lesson: lesson as Lesson, - }, - } satisfies GetStaticPathsItem); - } - } + routes.push({ + params: { + slug: `/${slug}`, + }, + props: { + title, + lesson: lesson as Lesson, + logoLink: tutorial.logoLink, + navList: generateNavigationList(tutorial, import.meta.env.BASE_URL), + }, + } satisfies GetStaticPathsItem); } return routes satisfies ReturnType; diff --git a/packages/react/src/Nav.tsx b/packages/react/src/Nav.tsx index 7145e4b98..12010e5eb 100644 --- a/packages/react/src/Nav.tsx +++ b/packages/react/src/Nav.tsx @@ -1,7 +1,7 @@ import * as Accordion from '@radix-ui/react-accordion'; import { interpolateString, type Lesson, type NavItem, type NavList } from '@tutorialkit/types'; import { AnimatePresence, cubicBezier, motion } from 'framer-motion'; -import { useCallback, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { useOutsideClick } from './hooks/useOutsideClick.js'; import navStyles from './styles/nav.module.css'; import { classNames } from './utils/classnames.js'; @@ -13,16 +13,25 @@ interface Props { navList: NavList; } +interface NavListItemProps { + level: number; + activeItems: NavItem['id'][]; + index: number; + i18n: Lesson['data']['i18n']; +} + export function Nav({ lesson: currentLesson, navList }: Props) { const menuRef = useRef(null); const [showDropdown, setShowDropdown] = useState(false); const { prev, next } = currentLesson; - const onOutsideClick = useCallback(() => { - setShowDropdown(false); - }, []); + const activeItems = [ + currentLesson.part?.id || currentLesson.id, + currentLesson.chapter?.id || currentLesson.id, + currentLesson.id, + ]; - useOutsideClick(menuRef, onOutsideClick); + useOutsideClick(menuRef, () => setShowDropdown(false)); return (
@@ -53,10 +62,18 @@ export function Nav({ lesson: currentLesson, navList }: Props) { onClick={() => setShowDropdown(!showDropdown)} >
- {currentLesson.part.title} - / - {currentLesson.chapter.title} - / + {currentLesson.part && ( + <> + {currentLesson.part.title} + / + + )} + {currentLesson.chapter && ( + <> + {currentLesson.chapter.title} + / + + )} {currentLesson.data.title}
- {renderParts(navList, currentLesson, onOutsideClick)} + )} @@ -95,123 +118,67 @@ export function Nav({ lesson: currentLesson, navList }: Props) { ); } -function renderParts(navList: NavList, currentLesson: Lesson, onLinkClick: () => void) { +function NavListComponent({ + items, + level, + activeItems, + className, + i18n, +}: Omit & { items: NavList; className?: string }) { return ( -
    - - {navList.map((part, partIndex) => { - const isPartActive = part.id === currentLesson.part.id; - - return ( -
  • - - - - - {interpolateString(currentLesson.data.i18n!.partTemplate!, { - index: partIndex + 1, - title: part.title, - })} - - - - {renderChapters(currentLesson, part, isPartActive, onLinkClick)} - - -
  • - ); - })} -
    -
+ +
    + {items.map((item, index) => ( + + ))} +
+
); } -function renderChapters(currentLesson: Lesson, part: NavItem, isPartActive: boolean, onLinkClick: () => void) { - return ( -
    - - {part.sections?.map((chapter, chapterIndex) => { - const isChapterActive = isPartActive && currentLesson.chapter.id === chapter.id; +function NavListItem({ level, type, index, i18n, activeItems, id, title, href, sections }: NavItem & NavListItemProps) { + const isActive = activeItems[level] === id; - return ( -
  • - - - - {chapter.title} - - - {renderLessons(currentLesson, chapter, isPartActive, isChapterActive, onLinkClick)} - - -
  • - ); - })} -
    -
- ); -} + if (!sections) { + return ( +
  • + + {title} + +
  • + ); + } -function renderLessons( - currentLesson: Lesson, - chapter: NavItem, - isPartActive: boolean, - isChapterActive: boolean, - onLinkClick: () => void, -) { return ( -
      - {chapter.sections?.map((lesson, lessonIndex) => { - const isActiveLesson = isPartActive && isChapterActive && lesson.id === currentLesson.id; + +
    • + + + {type === 'part' ? interpolateString(i18n!.partTemplate!, { index: index + 1, title }) : title} + - return ( -
    • - - {lesson.title} - -
    • - ); - })} -
    + + + + + ); } diff --git a/packages/types/src/entities/index.ts b/packages/types/src/entities/index.ts index 3d0abf5f6..ed72fefa3 100644 --- a/packages/types/src/entities/index.ts +++ b/packages/types/src/entities/index.ts @@ -25,8 +25,7 @@ export interface Part { order: number; slug: string; data: PartSchema; - firstChapterId?: string; - chapters: Record; + chapters: Record; } export interface Chapter { @@ -34,16 +33,14 @@ export interface Chapter { order: number; slug: string; data: ChapterSchema; - firstLessonId?: string; - lessons: Record; } export interface Lesson { id: string; order: number; data: LessonSchema; - part: { id: string; title: string }; - chapter: { id: string; title: string }; + part?: { id: Part['id']; title: string }; + chapter?: { id: Chapter['id']; title: string }; slug: string; filepath: string; editPageLink?: string; @@ -64,6 +61,6 @@ export type CustomConfig = CustomSchema; export interface Tutorial { logoLink?: string; - firstPartId?: string; - parts: Record; + parts: Record; + lessons: Lesson[]; } diff --git a/packages/types/src/entities/nav.ts b/packages/types/src/entities/nav.ts index 9c0b21517..59dcd626e 100644 --- a/packages/types/src/entities/nav.ts +++ b/packages/types/src/entities/nav.ts @@ -1,6 +1,7 @@ export interface NavItem { id: string; title: string; + type?: 'part' | 'chapter' | 'lesson'; href?: string; sections?: NavItem[]; } diff --git a/packages/types/src/schemas/part.ts b/packages/types/src/schemas/part.ts index ce3682e1b..0d269d011 100644 --- a/packages/types/src/schemas/part.ts +++ b/packages/types/src/schemas/part.ts @@ -9,6 +9,12 @@ export const partSchema = baseSchema.extend({ .describe( 'The list of chapters in this part. The order of this array defines the order of the chapters. If not specified a folder-based numbering system is used instead.', ), + lessons: z + .array(z.string()) + .optional() + .describe( + 'The list of lessons in this part. The order of this array defines the order of the lessons. If not specified a folder-based numbering system is used instead.', + ), }); export type PartSchema = z.infer; diff --git a/packages/types/src/schemas/tutorial.ts b/packages/types/src/schemas/tutorial.ts index 6ae8bf4fc..4a97c271a 100644 --- a/packages/types/src/schemas/tutorial.ts +++ b/packages/types/src/schemas/tutorial.ts @@ -10,6 +10,12 @@ export const tutorialSchema = webcontainerSchema.extend({ .describe( 'The list of parts in this tutorial. The order of this array defines the order of the parts. If not specified a folder-based numbering system is used instead.', ), + lessons: z + .array(z.string()) + .optional() + .describe( + 'The list of lessons in this tutorial. The order of this array defines the order of the lessons. If not specified a folder-based numbering system is used instead.', + ), }); export type TutorialSchema = z.infer;