diff --git a/packages/astro/src/default/pages/index.astro b/packages/astro/src/default/pages/index.astro index ad13a361f..974c4b9a2 100644 --- a/packages/astro/src/default/pages/index.astro +++ b/packages/astro/src/default/pages/index.astro @@ -3,10 +3,9 @@ import { getTutorial } from '../utils/content'; const tutorial = await getTutorial(); -const parts = Object.values(tutorial.parts); -const part = parts[0]; -const chapter = part.chapters[1]; -const lesson = chapter.lessons[1]; +const part = tutorial.parts[tutorial.firstPartId!]; +const chapter = part.chapters[part?.firstChapterId!]; +const lesson = chapter.lessons[chapter?.firstLessonId!]; const redirect = `/${part.slug}/${chapter.slug}/${lesson.slug}`; --- diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts index da7d09988..d24675977 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -9,6 +9,7 @@ import type { } from '@tutorialkit/types'; import { folderPathToFilesRef } from '@tutorialkit/types'; import { getCollection } from 'astro:content'; +import { logger } from './logger'; import glob from 'fast-glob'; import path from 'node:path'; @@ -22,14 +23,13 @@ export async function getTutorial(): Promise { }; let tutorialMetaData: TutorialSchema | undefined; - - const lessons: Lesson[] = []; + let lessons: Lesson[] = []; for (const entry of collection) { const { id, data } = entry; const { type } = data; - const [partId, chapterId, lessonId] = parseId(id); + const [partId, chapterId, lessonId] = id.split('/'); if (type === 'tutorial') { tutorialMetaData = data; @@ -41,6 +41,7 @@ export async function getTutorial(): Promise { } else if (type === 'part') { _tutorial.parts[partId] = { id: partId, + order: -1, data, slug: getSlug(entry), chapters: {}, @@ -52,6 +53,7 @@ export async function getTutorial(): Promise { _tutorial.parts[partId].chapters[chapterId] = { id: chapterId, + order: -1, data, slug: getSlug(entry), lessons: {}, @@ -77,6 +79,7 @@ export async function getTutorial(): Promise { const lesson: Lesson = { data, id: lessonId, + order: -1, part: { id: partId, title: _tutorial.parts[partId].data.title, @@ -97,20 +100,133 @@ export async function getTutorial(): Promise { } } + 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 '${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 '${chapterId}', it won't be part of the part '${partId}'.`); + continue; + } + + if (!part.firstChapterId) { + part.firstChapterId = chapterId; + } + + chapter.order = c; + + const lessonOrder = getOrder(chapter.data.lessons, chapter.lessons); + + for (let l = 0; l < lessonOrder.length; ++l) { + const lessonId = lessonOrder[l]; + const lesson = chapter.lessons[lessonId]; + + if (!lesson) { + logger.warn(`Could not find '${lessonId}', it won't be part of the chapter '${chapterId}'.`); + continue; + } + + if (!chapter.firstLessonId) { + chapter.firstLessonId = lessonId; + } + + lesson.order = l; + } + } + } + + // 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 (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; + } + + for (const lessonId in chapter.lessons) { + const lesson = chapter.lessons[lessonId]; + + 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; + } + } + } + } + + // sort lessons lessons.sort((a, b) => { - const partsA = [a.part.id, a.chapter.id, a.id] as const; - const partsB = [b.part.id, b.chapter.id, b.id] as const; + 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 Number(partsA[i]) - Number(partsB[i]); + return partsA[i] - partsB[i]; } } return 0; }); - // now we link all tutorials together + // 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); @@ -167,43 +283,27 @@ function pick>(objects: (T | undefined)[], properties return newObject; } -function sortCollection(collection: CollectionEntryTutorial[]) { - return collection.sort((a, b) => { - const splitA = a.id.split('/'); - const splitB = b.id.split('/'); - - const depthA = splitA.length; - const depthB = splitB.length; - - if (depthA !== depthB) { - return depthA - depthB; - } +function getOrder(order: string[] | undefined, fallbackSourceForOrder: Record): string[] { + if (order) { + return order; + } - for (let i = 0; i < splitA.length; i++) { - const numA = parseInt(splitA[i], 10); - const numB = parseInt(splitB[i], 10); + // default to an order based on having each folder prefixed by their order: `1-foo`, `2-bar`, ... + return Object.keys(fallbackSourceForOrder).sort((a, b) => { + const numA = parseInt(a, 10); + const numB = parseInt(b, 10); - if (!isNaN(numA) && !isNaN(numB) && numA !== numB) { - return numA - numB; - } else { - if (splitA[i] !== splitB[i]) { - return splitA[i].localeCompare(splitB[i]); - } - } - } - - return 0; + return numA - numB; }); } -function parseId(id: string) { - const [part, chapter, lesson] = id.split('/'); - - const [partId] = part.split('-'); - const [chapterId] = chapter?.split('-') ?? []; - const [lessonId] = lesson?.split('-') ?? []; +function sortCollection(collection: CollectionEntryTutorial[]) { + return collection.sort((a, b) => { + const depthA = a.id.split('/').length; + const depthB = b.id.split('/').length; - return [partId, chapterId, lessonId]; + return depthA - depthB; + }); } function getSlug(entry: CollectionEntryTutorial) { diff --git a/packages/astro/src/default/utils/logger.ts b/packages/astro/src/default/utils/logger.ts new file mode 100644 index 000000000..3b1673319 --- /dev/null +++ b/packages/astro/src/default/utils/logger.ts @@ -0,0 +1,54 @@ +/** + * Largely taken from Astro logger implementation. + * + * @see https://github.com/withastro/astro/blob/c44f7f4babbb19350cd673241136bc974b012d51/packages/astro/src/core/logger/core.ts#L200 + */ + +import { blue, bold, dim, red, yellow } from 'kleur/colors'; + +const dateTimeFormat = new Intl.DateTimeFormat([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}); + +function getEventPrefix(level: 'info' | 'error' | 'warn', label: string) { + const timestamp = `${dateTimeFormat.format(new Date())}`; + const prefix = []; + + if (level === 'error' || level === 'warn') { + prefix.push(bold(timestamp)); + prefix.push(`[${level.toUpperCase()}]`); + } else { + prefix.push(timestamp); + } + + if (label) { + prefix.push(`[${label}]`); + } + + if (level === 'error') { + return red(prefix.join(' ')); + } + if (level === 'warn') { + return yellow(prefix.join(' ')); + } + + if (prefix.length === 1) { + return dim(prefix[0]); + } + return dim(prefix[0]) + ' ' + blue(prefix.splice(1).join(' ')); +} + +export const logger = { + warn(message: string) { + console.log(getEventPrefix('warn', 'tutorialkit') + ' ' + message); + }, + error(message: string) { + console.error(getEventPrefix('error', 'tutorialkit') + ' ' + message); + }, + info(message: string) { + console.log(getEventPrefix('info', 'tutorialkit') + ' ' + message); + }, +}; diff --git a/packages/astro/src/default/utils/nav.ts b/packages/astro/src/default/utils/nav.ts index 6831d4b10..8bc8ceaea 100644 --- a/packages/astro/src/default/utils/nav.ts +++ b/packages/astro/src/default/utils/nav.ts @@ -22,8 +22,8 @@ export function generateNavigationList(tutorial: Tutorial): NavList { }); } -function objectToSortedArray>(object: T): Array { +function objectToSortedArray>(object: T): Array { return Object.keys(object) - .sort((a, b) => Number(a) - Number(b)) - .map((key) => object[key]); + .map((key) => object[key] as T[keyof T]) + .sort((a, b) => a.order - b.order); } diff --git a/packages/types/src/entities/index.ts b/packages/types/src/entities/index.ts index c5622208c..61846baab 100644 --- a/packages/types/src/entities/index.ts +++ b/packages/types/src/entities/index.ts @@ -17,20 +17,25 @@ export interface LessonLink { export interface Part { id: string; + order: number; slug: string; data: PartSchema; + firstChapterId?: string; chapters: Record; } export interface Chapter { id: string; + 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 }; @@ -46,5 +51,6 @@ export interface Lesson { export interface Tutorial { logoLink?: string; + firstPartId?: string; parts: Record; } diff --git a/packages/types/src/schemas/chapter.ts b/packages/types/src/schemas/chapter.ts index d5584e16d..7b0cff182 100644 --- a/packages/types/src/schemas/chapter.ts +++ b/packages/types/src/schemas/chapter.ts @@ -3,6 +3,7 @@ import { baseSchema } from './common.js'; export const chapterSchema = baseSchema.extend({ type: z.literal('chapter'), + lessons: z.array(z.string()).optional(), }); export type ChapterSchema = z.infer; diff --git a/packages/types/src/schemas/part.ts b/packages/types/src/schemas/part.ts index 3b80be7d4..cce512857 100644 --- a/packages/types/src/schemas/part.ts +++ b/packages/types/src/schemas/part.ts @@ -3,6 +3,7 @@ import { baseSchema } from './common.js'; export const partSchema = baseSchema.extend({ type: z.literal('part'), + chapters: z.array(z.string()).optional(), }); export type PartSchema = z.infer; diff --git a/packages/types/src/schemas/tutorial.ts b/packages/types/src/schemas/tutorial.ts index c1c21ff54..0f86c1339 100644 --- a/packages/types/src/schemas/tutorial.ts +++ b/packages/types/src/schemas/tutorial.ts @@ -4,6 +4,7 @@ import { webcontainerSchema } from './common.js'; export const tutorialSchema = webcontainerSchema.extend({ type: z.literal('tutorial'), logoLink: z.string().optional(), + parts: z.array(z.string()).optional(), }); export type TutorialSchema = z.infer;