Skip to content

feat: allow ordering to be config based in addition to folder based #79

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions packages/astro/src/default/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Comment on lines -8 to -9
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this 1-based?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The folder were expected to start at 1. We now instead pick the first folder that comes up. It could start at anything: 0, 4, ... or be explicitly defined.

So this is a new tiny feature! 😃

const part = tutorial.parts[tutorial.firstPartId!];
const chapter = part.chapters[part?.firstChapterId!];
const lesson = chapter.lessons[chapter?.firstLessonId!];
Comment on lines +7 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very confused. So all the firstPartId and firstChapterId and firstLessonId are defined as optional in entities/index.ts. Why are we here telling TS that they will be defined?

Also, what if they are defined but they don't exist? Then chapter will be undefined and it will throw, no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct! It's actually the same behaviour as before because there would be no part either.

Note to be in that situation you must not have a part, nor a lesson nor a chapter. Because if you have any, the getTutorial will throw first.


const redirect = `/${part.slug}/${chapter.slug}/${lesson.slug}`;
---
Expand Down
176 changes: 138 additions & 38 deletions packages/astro/src/default/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,14 +23,13 @@ export async function getTutorial(): Promise<Tutorial> {
};

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;
Expand All @@ -41,6 +41,7 @@ export async function getTutorial(): Promise<Tutorial> {
} else if (type === 'part') {
_tutorial.parts[partId] = {
id: partId,
order: -1,
data,
slug: getSlug(entry),
chapters: {},
Expand All @@ -52,6 +53,7 @@ export async function getTutorial(): Promise<Tutorial> {

_tutorial.parts[partId].chapters[chapterId] = {
id: chapterId,
order: -1,
data,
slug: getSlug(entry),
lessons: {},
Expand All @@ -77,6 +79,7 @@ export async function getTutorial(): Promise<Tutorial> {
const lesson: Lesson = {
data,
id: lessonId,
order: -1,
part: {
id: partId,
title: _tutorial.parts[partId].data.title,
Expand All @@ -97,20 +100,133 @@ export async function getTutorial(): Promise<Tutorial> {
}
}

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);
Expand Down Expand Up @@ -167,43 +283,27 @@ function pick<T extends Record<any, any>>(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, any>): 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) {
Expand Down
54 changes: 54 additions & 0 deletions packages/astro/src/default/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
6 changes: 3 additions & 3 deletions packages/astro/src/default/utils/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export function generateNavigationList(tutorial: Tutorial): NavList {
});
}

function objectToSortedArray<T extends Record<any, any>>(object: T): Array<T[keyof T]> {
function objectToSortedArray<T extends Record<any, { order: number }>>(object: T): Array<T[keyof T]> {
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);
}
6 changes: 6 additions & 0 deletions packages/types/src/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,25 @@ export interface LessonLink {

export interface Part {
id: string;
order: number;
slug: string;
data: PartSchema;
firstChapterId?: string;
chapters: Record<string, Chapter>;
}

export interface Chapter {
id: string;
order: number;
slug: string;
data: ChapterSchema;
firstLessonId?: string;
lessons: Record<string, Lesson>;
}

export interface Lesson<T = unknown> {
id: string;
order: number;
data: LessonSchema;
part: { id: string; title: string };
chapter: { id: string; title: string };
Expand All @@ -46,5 +51,6 @@ export interface Lesson<T = unknown> {

export interface Tutorial {
logoLink?: string;
firstPartId?: string;
parts: Record<string, Part>;
}
1 change: 1 addition & 0 deletions packages/types/src/schemas/chapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof chapterSchema>;
1 change: 1 addition & 0 deletions packages/types/src/schemas/part.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof partSchema>;
1 change: 1 addition & 0 deletions packages/types/src/schemas/tutorial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof tutorialSchema>;