diff --git a/packages/astro/src/default/pages/index.astro b/packages/astro/src/default/pages/index.astro index 5e81da04..f832ed32 100644 --- a/packages/astro/src/default/pages/index.astro +++ b/packages/astro/src/default/pages/index.astro @@ -4,25 +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 = tutorial.lessons.find((l) => l.id === 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]; -if (!lesson) { - throw new Error( - `Unable to find lesson for ${JSON.stringify( - { - partId: tutorial.firstPartId || null, - chapterId: part.firstChapterId || null, - lessonId: chapter.firstLessonId || null, - }, - null, - 2, - )}`, - ); -} +const slug = [part?.slug, chapter?.slug, lesson.slug].filter(Boolean).join('/'); -const redirect = joinPaths(import.meta.env.BASE_URL, `/${part.slug}/${chapter.slug}/${lesson.slug}`); +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 8f4e842e..6e1cc865 100644 --- a/packages/astro/src/default/utils/__snapshots__/multiple-parts.json +++ b/packages/astro/src/default/utils/__snapshots__/multiple-parts.json @@ -81,14 +81,6 @@ "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": [ @@ -99,6 +91,14 @@ "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" @@ -116,15 +116,7 @@ }, "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" - }, + "order": 1, "Markdown": "Markdown for tutorial", "slug": "lesson-slug", "files": [ @@ -135,6 +127,14 @@ "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" @@ -156,15 +156,7 @@ }, "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" - }, + "order": 2, "Markdown": "Markdown for tutorial", "slug": "lesson-slug", "files": [ @@ -175,6 +167,14 @@ "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" 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 00000000..8455d92c --- /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 00000000..96970eb9 --- /dev/null +++ b/packages/astro/src/default/utils/__snapshots__/single-part-and-lesson-no-chapter.json @@ -0,0 +1,45 @@ +{ + "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" + } + } + ], + "firstPartId": "1-part" +} \ 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 127c7ea7..bb78e28f 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 @@ -37,14 +37,6 @@ "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": [ @@ -54,7 +46,15 @@ "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" + } } ], "firstPartId": "1-part" 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 aa8097d1..e61c4d9a 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 @@ -37,14 +37,6 @@ "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": [ @@ -55,6 +47,14 @@ "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" @@ -73,14 +73,6 @@ "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": [ @@ -91,6 +83,14 @@ "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" @@ -113,14 +113,6 @@ "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": [ @@ -131,6 +123,14 @@ "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" 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 0f40e089..4b09d01b 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 @@ -57,14 +57,6 @@ "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": [ @@ -75,6 +67,14 @@ "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" @@ -92,15 +92,7 @@ }, "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" - }, + "order": 1, "Markdown": "Markdown for tutorial", "slug": "lesson-slug", "files": [ @@ -111,6 +103,14 @@ "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" @@ -132,15 +132,7 @@ }, "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" - }, + "order": 2, "Markdown": "Markdown for tutorial", "slug": "lesson-slug", "files": [ @@ -151,6 +143,14 @@ "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" diff --git a/packages/astro/src/default/utils/content.spec.ts b/packages/astro/src/default/utils/content.spec.ts index bb9be9ce..fe47b418 100644 --- a/packages/astro/src/default/utils/content.spec.ts +++ b/packages/astro/src/default/utils/content.spec.ts @@ -129,11 +129,41 @@ test('lessons with identical names in different chapters', async () => { 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].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'); + expect(lessons[0].part?.id).toBe('1-part'); + expect(lessons[1].part?.id).toBe('1-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', () => { @@ -361,7 +391,7 @@ describe('ordering', () => { 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 }, @@ -391,6 +421,63 @@ describe('ordering', () => { 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[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 () => { vi.spyOn(logger, 'warn').mockImplementationOnce(vi.fn()); @@ -433,7 +520,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 }, @@ -444,6 +531,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 }, @@ -456,6 +552,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 57adda4b..43935c3e 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -19,7 +19,128 @@ 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: [], }; @@ -30,7 +151,7 @@ export async function getTutorial(): Promise { 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; @@ -40,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, @@ -50,23 +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), }; } 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(); @@ -83,211 +208,43 @@ 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, }; - _tutorial.lessons.push(lesson); - } - } - - if (!tutorialMetaData) { - 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]; - - if (!part) { - logger.warn(`Could not find part '${partId}', it won't be part of the tutorial.`); - continue; - } - - if (!_tutorial.firstPartId) { - _tutorial.firstPartId = partId; - } - - part.order = p; - - const chapterOrder = getOrder(part.data.chapters, part.chapters); - - for (let c = 0; c < chapterOrder.length; ++c) { - const chapterId = chapterOrder[c]; - const chapter = part.chapters[chapterId]; - - if (!chapter) { - logger.warn(`Could not find chapter '${chapterId}', it won't be part of the part '${partId}'.`); - continue; - } - - if (!part.firstChapterId) { - part.firstChapterId = chapterId; - } - - chapter.order = c; - - const chapterLessons = _tutorial.lessons.filter((l) => l.part.id === partId && l.chapter.id === chapterId); - const lessonOrder = getOrder( - chapter.data.lessons, - chapterLessons.map((l) => l.id), - ); - - for (let l = 0; l < lessonOrder.length; ++l) { - const lessonId = lessonOrder[l]; - const lesson = chapterLessons.find((l) => l.id === lessonId); - - if (!lesson) { - logger.warn(`Could not find lesson '${lessonId}', it won't be part of the chapter '${chapterId}'.`); - continue; - } - - if (!chapter.firstLessonId) { - chapter.firstLessonId = lessonId; + if (partId) { + if (!tutorial.parts[partId]) { + throw new Error(`Could not find part '${partId}'`); } - lesson.order = l; - } - } - } - - // 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; + lesson.part = { + id: partId, + title: tutorial.parts[partId].data.title, + }; } - const chapterLessons = _tutorial.lessons.filter((l) => l.chapter.id === chapterId); - - 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; + if (partId && chapterId) { + if (!tutorial.parts[partId].chapters[chapterId]) { + throw new Error(`Could not find chapter '${chapterId}'`); } - } - } - } - // removed orphaned lessons - _tutorial.lessons = _tutorial.lessons.filter( - (lesson) => - lesson.order !== -1 && - _tutorial.parts[lesson.part.id].order !== -1 && - _tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].order !== -1, - ); - - // sort lessons - _tutorial.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]; + lesson.chapter = { + id: chapterId, + title: tutorial.parts[partId].chapters[chapterId].data.title, + }; } - } - - return 0; - }); - - const baseURL = import.meta.env.BASE_URL; - - // now we link all lessons together - 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); - const partMetadata = _tutorial.parts[lesson.part.id].data; - const chapterMetadata = _tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].data; - - lesson.data = { - ...lesson.data, - ...squash( - [lesson.data, chapterMetadata, partMetadata, tutorialMetaData], - [ - 'mainCommand', - 'prepareCommands', - 'previews', - 'autoReload', - 'template', - 'terminal', - 'editor', - 'focus', - 'i18n', - 'meta', - 'editPageLink', - 'openInStackBlitz', - 'filesystem', - ], - ), - }; - - if (prevLesson) { - const partSlug = _tutorial.parts[prevLesson.part.id].slug; - const chapterSlug = _tutorial.parts[prevLesson.part.id].chapters[prevLesson.chapter.id].slug; - - lesson.prev = { - title: prevLesson.data.title, - href: joinPaths(baseURL, `/${partSlug}/${chapterSlug}/${prevLesson.slug}`), - }; - } - - if (nextLesson) { - const partSlug = _tutorial.parts[nextLesson.part.id].slug; - const chapterSlug = _tutorial.parts[nextLesson.part.id].chapters[nextLesson.chapter.id].slug; - - lesson.next = { - title: nextLesson.data.title, - href: joinPaths(baseURL, `/${partSlug}/${chapterSlug}/${nextLesson.slug}`), - }; + tutorial.lessons.push(lesson); } + } - if (lesson.data.editPageLink && typeof lesson.data.editPageLink === 'string') { - lesson.editPageLink = interpolateString(lesson.data.editPageLink, { path: lesson.filepath }); - } + if (!tutorialMetaData) { + throw new Error(`Could not find tutorial 'meta.md' file`); } - return _tutorial; + return { tutorial, tutorialMetaData }; } function getOrder( @@ -337,6 +294,142 @@ function getSlug(entry: CollectionEntryTutorial) { return slug; } +function resolveIds( + id: string, + type: CollectionEntryTutorial['data']['type'], +): { partId?: string; chapterId?: string; lessonId?: string } { + const parts = id.split('/'); + + if (type === 'tutorial') { + return {}; + } + + if (type === 'part') { + return { + partId: parts[0], + }; + } + + if (type === 'chapter') { + return { + partId: parts[0], + chapterId: parts[1], + }; + } + + /** + * 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 (parts.length === 3) { + return { + partId: parts[0], + lessonId: parts[1], + }; + } + + return { + partId: parts[0], + chapterId: parts[1], + lessonId: parts[2], + }; +} + +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.', + ); + } + + // 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; + } + + 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.`, + ); + } + } +} + +function sortTutorialLessons(tutorial: Tutorial, metadata: TutorialSchema) { + const lessonOrder: Lesson['id'][] = []; + const lessonIds = tutorial.lessons.map((lesson) => lesson.id); + + const lessonsInRoot = Object.keys(tutorial.parts).length === 0; + + // if lessons in root, sort by tutorial.lessons and metadata.lessons + if (lessonsInRoot) { + lessonOrder.push(...getOrder(metadata.lessons, lessonIds)); + } + + // 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 (!part) { + continue; + } + + part.order = partOrder; + tutorial.firstPartId ??= part.id; + + const partLessons = tutorial.lessons + .filter((lesson) => lesson.chapter == null && lesson.part?.id === partId) + .map((lesson) => lesson.id); + + // all lessons are in part, no chapters + if (partLessons.length) { + lessonOrder.push(...getOrder(part.data.lessons, partLessons)); + continue; + } + + // lessons in chapters + for (const [chapterOrder, chapterId] of getOrder(part.data.chapters, part.chapters).entries()) { + const chapter = part.chapters[chapterId]; + + if (!chapter) { + continue; + } + + chapter.order = chapterOrder; + part.firstChapterId ??= chapter.id; + + const chapterLessons = tutorial.lessons + .filter((lesson) => lesson.chapter?.id === chapter.id && lesson.part?.id === partId) + .map((lesson) => lesson.id); + + const chapterLessonOrder = getOrder(chapter.data.lessons, chapterLessons); + + chapter.firstLessonId ??= chapterLessonOrder[0]; + + lessonOrder.push(...chapterLessonOrder); + } + } + } + + // finally apply overall order for lessons + for (const lesson of tutorial.lessons) { + lesson.order = lessonOrder.indexOf(lesson.id); + } + + tutorial.lessons.sort((a, b) => a.order - b.order); +} + export interface CollectionEntryTutorial { id: string; slug: string; diff --git a/packages/astro/src/default/utils/nav.ts b/packages/astro/src/default/utils/nav.ts index 13efb7dd..76a736ad 100644 --- a/packages/astro/src/default/utils/nav.ts +++ b/packages/astro/src/default/utils/nav.ts @@ -1,38 +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) => { - const lessons = tutorial.lessons.filter( - (lesson) => lesson.part.id === part.id && lesson.chapter.id === chapter.id, - ); - - return { - id: chapter.id, - title: chapter.data.title, - sections: lessons.sort(sortByOrder).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(sortByOrder); -} + if (chapterItem) { + chapterItem.sections.push(lessonItem); + } else if (partItem) { + partItem.sections.push(lessonItem); + } else { + list.push(lessonItem); + } + } -function sortByOrder(a: T, b: T) { - return a.order - b.order; + return list; } diff --git a/packages/astro/src/default/utils/routes.ts b/packages/astro/src/default/utils/routes.ts index c4c80f18..01ff92ac 100644 --- a/packages/astro/src/default/utils/routes.ts +++ b/packages/astro/src/default/utils/routes.ts @@ -11,15 +11,18 @@ export async function generateStaticRoutes() { const lessons = Object.values(tutorial.lessons); for (const lesson of lessons) { - const part = tutorial.parts[lesson.part.id]; - const chapter = part.chapters[lesson.chapter.id]; + const part = lesson.part && tutorial.parts[lesson.part.id]; + const chapter = lesson.chapter && part?.chapters[lesson.chapter.id]; + + 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(' / '); routes.push({ params: { - slug: `/${part.slug}/${chapter.slug}/${lesson.slug}`, + slug: `/${slug}`, }, props: { - title: `${lesson.part.title} / ${lesson.chapter.title} / ${lesson.data.title}`, + title, lesson: lesson as Lesson, logoLink: tutorial.logoLink, navList: generateNavigationList(tutorial, import.meta.env.BASE_URL), diff --git a/packages/react/src/Nav.tsx b/packages/react/src/Nav.tsx index 7145e4b9..2e4e6811 100644 --- a/packages/react/src/Nav.tsx +++ b/packages/react/src/Nav.tsx @@ -1,11 +1,13 @@ 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'; +// TODO: Compare visual styles with old implementation + const dropdownEasing = cubicBezier(0.4, 0, 0.2, 1); interface Props { @@ -13,16 +15,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 +64,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 +120,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 46b7f28c..df068e19 100644 --- a/packages/types/src/entities/index.ts +++ b/packages/types/src/entities/index.ts @@ -41,8 +41,8 @@ export interface Lesson { id: string; order: number; data: LessonSchema; - part: { id: Part['id']; title: string }; - chapter: { id: Chapter['id']; title: string }; + part?: { id: Part['id']; title: string }; + chapter?: { id: Chapter['id']; title: string }; slug: string; filepath: string; editPageLink?: string; diff --git a/packages/types/src/entities/nav.ts b/packages/types/src/entities/nav.ts index 9c0b2151..59dcd626 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 ce3682e1..0d269d01 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 6ae8bf4f..4a97c271 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;