Skip to content

Commit 2b131e5

Browse files
authored
feat: allow ordering to be config based in addition to folder based (stackblitz#79)
1 parent e188412 commit 2b131e5

File tree

8 files changed

+207
-45
lines changed

8 files changed

+207
-45
lines changed

packages/astro/src/default/pages/index.astro

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import { getTutorial } from '../utils/content';
33
44
const tutorial = await getTutorial();
55
6-
const parts = Object.values(tutorial.parts);
7-
const part = parts[0];
8-
const chapter = part.chapters[1];
9-
const lesson = chapter.lessons[1];
6+
const part = tutorial.parts[tutorial.firstPartId!];
7+
const chapter = part.chapters[part?.firstChapterId!];
8+
const lesson = chapter.lessons[chapter?.firstLessonId!];
109
1110
const redirect = `/${part.slug}/${chapter.slug}/${lesson.slug}`;
1211
---

packages/astro/src/default/utils/content.ts

+138-38
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from '@tutorialkit/types';
1010
import { folderPathToFilesRef } from '@tutorialkit/types';
1111
import { getCollection } from 'astro:content';
12+
import { logger } from './logger';
1213
import glob from 'fast-glob';
1314
import path from 'node:path';
1415

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

2425
let tutorialMetaData: TutorialSchema | undefined;
25-
26-
const lessons: Lesson[] = [];
26+
let lessons: Lesson[] = [];
2727

2828
for (const entry of collection) {
2929
const { id, data } = entry;
3030
const { type } = data;
3131

32-
const [partId, chapterId, lessonId] = parseId(id);
32+
const [partId, chapterId, lessonId] = id.split('/');
3333

3434
if (type === 'tutorial') {
3535
tutorialMetaData = data;
@@ -41,6 +41,7 @@ export async function getTutorial(): Promise<Tutorial> {
4141
} else if (type === 'part') {
4242
_tutorial.parts[partId] = {
4343
id: partId,
44+
order: -1,
4445
data,
4546
slug: getSlug(entry),
4647
chapters: {},
@@ -52,6 +53,7 @@ export async function getTutorial(): Promise<Tutorial> {
5253

5354
_tutorial.parts[partId].chapters[chapterId] = {
5455
id: chapterId,
56+
order: -1,
5557
data,
5658
slug: getSlug(entry),
5759
lessons: {},
@@ -77,6 +79,7 @@ export async function getTutorial(): Promise<Tutorial> {
7779
const lesson: Lesson = {
7880
data,
7981
id: lessonId,
82+
order: -1,
8083
part: {
8184
id: partId,
8285
title: _tutorial.parts[partId].data.title,
@@ -97,20 +100,133 @@ export async function getTutorial(): Promise<Tutorial> {
97100
}
98101
}
99102

103+
if (!tutorialMetaData) {
104+
throw new Error(`Could not find tutorial 'meta.md' file`);
105+
}
106+
107+
// let's now compute the order for everything
108+
const partsOrder = getOrder(tutorialMetaData.parts, _tutorial.parts);
109+
110+
for (let p = 0; p < partsOrder.length; ++p) {
111+
const partId = partsOrder[p];
112+
const part = _tutorial.parts[partId];
113+
114+
if (!part) {
115+
logger.warn(`Could not find '${partId}', it won't be part of the tutorial.`);
116+
continue;
117+
}
118+
119+
if (!_tutorial.firstPartId) {
120+
_tutorial.firstPartId = partId;
121+
}
122+
123+
part.order = p;
124+
125+
const chapterOrder = getOrder(part.data.chapters, part.chapters);
126+
127+
for (let c = 0; c < chapterOrder.length; ++c) {
128+
const chapterId = chapterOrder[c];
129+
const chapter = part.chapters[chapterId];
130+
131+
if (!chapter) {
132+
logger.warn(`Could not find '${chapterId}', it won't be part of the part '${partId}'.`);
133+
continue;
134+
}
135+
136+
if (!part.firstChapterId) {
137+
part.firstChapterId = chapterId;
138+
}
139+
140+
chapter.order = c;
141+
142+
const lessonOrder = getOrder(chapter.data.lessons, chapter.lessons);
143+
144+
for (let l = 0; l < lessonOrder.length; ++l) {
145+
const lessonId = lessonOrder[l];
146+
const lesson = chapter.lessons[lessonId];
147+
148+
if (!lesson) {
149+
logger.warn(`Could not find '${lessonId}', it won't be part of the chapter '${chapterId}'.`);
150+
continue;
151+
}
152+
153+
if (!chapter.firstLessonId) {
154+
chapter.firstLessonId = lessonId;
155+
}
156+
157+
lesson.order = l;
158+
}
159+
}
160+
}
161+
162+
// removed orphaned lessons
163+
lessons = lessons.filter(
164+
(lesson) =>
165+
lesson.order !== -1 &&
166+
_tutorial.parts[lesson.part.id].order !== -1 &&
167+
_tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].order !== -1,
168+
);
169+
170+
// find orphans discard them and print warnings
171+
for (const partId in _tutorial.parts) {
172+
const part = _tutorial.parts[partId];
173+
174+
if (part.order === -1) {
175+
delete _tutorial.parts[partId];
176+
logger.warn(
177+
`An order was specified for the parts of the tutorial but '${partId}' is not included so it won't be visible.`,
178+
);
179+
continue;
180+
}
181+
182+
for (const chapterId in part.chapters) {
183+
const chapter = part.chapters[chapterId];
184+
185+
if (chapter.order === -1) {
186+
delete part.chapters[chapterId];
187+
logger.warn(
188+
`An order was specified for part '${partId}' but chapter '${chapterId}' is not included, so it won't be visible.`,
189+
);
190+
continue;
191+
}
192+
193+
for (const lessonId in chapter.lessons) {
194+
const lesson = chapter.lessons[lessonId];
195+
196+
if (lesson.order === -1) {
197+
delete chapter.lessons[lessonId];
198+
logger.warn(
199+
`An order was specified for chapter '${chapterId}' but lesson '${lessonId}' is not included, so it won't be visible.`,
200+
);
201+
continue;
202+
}
203+
}
204+
}
205+
}
206+
207+
// sort lessons
100208
lessons.sort((a, b) => {
101-
const partsA = [a.part.id, a.chapter.id, a.id] as const;
102-
const partsB = [b.part.id, b.chapter.id, b.id] as const;
209+
const partsA = [
210+
_tutorial.parts[a.part.id].order,
211+
_tutorial.parts[a.part.id].chapters[a.chapter.id].order,
212+
a.order,
213+
] as const;
214+
const partsB = [
215+
_tutorial.parts[b.part.id].order,
216+
_tutorial.parts[b.part.id].chapters[b.chapter.id].order,
217+
b.order,
218+
] as const;
103219

104220
for (let i = 0; i < partsA.length; i++) {
105221
if (partsA[i] !== partsB[i]) {
106-
return Number(partsA[i]) - Number(partsB[i]);
222+
return partsA[i] - partsB[i];
107223
}
108224
}
109225

110226
return 0;
111227
});
112228

113-
// now we link all tutorials together
229+
// now we link all lessons together
114230
for (const [i, lesson] of lessons.entries()) {
115231
const prevLesson = i > 0 ? lessons.at(i - 1) : undefined;
116232
const nextLesson = lessons.at(i + 1);
@@ -167,43 +283,27 @@ function pick<T extends Record<any, any>>(objects: (T | undefined)[], properties
167283
return newObject;
168284
}
169285

170-
function sortCollection(collection: CollectionEntryTutorial[]) {
171-
return collection.sort((a, b) => {
172-
const splitA = a.id.split('/');
173-
const splitB = b.id.split('/');
174-
175-
const depthA = splitA.length;
176-
const depthB = splitB.length;
177-
178-
if (depthA !== depthB) {
179-
return depthA - depthB;
180-
}
286+
function getOrder(order: string[] | undefined, fallbackSourceForOrder: Record<string, any>): string[] {
287+
if (order) {
288+
return order;
289+
}
181290

182-
for (let i = 0; i < splitA.length; i++) {
183-
const numA = parseInt(splitA[i], 10);
184-
const numB = parseInt(splitB[i], 10);
291+
// default to an order based on having each folder prefixed by their order: `1-foo`, `2-bar`, ...
292+
return Object.keys(fallbackSourceForOrder).sort((a, b) => {
293+
const numA = parseInt(a, 10);
294+
const numB = parseInt(b, 10);
185295

186-
if (!isNaN(numA) && !isNaN(numB) && numA !== numB) {
187-
return numA - numB;
188-
} else {
189-
if (splitA[i] !== splitB[i]) {
190-
return splitA[i].localeCompare(splitB[i]);
191-
}
192-
}
193-
}
194-
195-
return 0;
296+
return numA - numB;
196297
});
197298
}
198299

199-
function parseId(id: string) {
200-
const [part, chapter, lesson] = id.split('/');
201-
202-
const [partId] = part.split('-');
203-
const [chapterId] = chapter?.split('-') ?? [];
204-
const [lessonId] = lesson?.split('-') ?? [];
300+
function sortCollection(collection: CollectionEntryTutorial[]) {
301+
return collection.sort((a, b) => {
302+
const depthA = a.id.split('/').length;
303+
const depthB = b.id.split('/').length;
205304

206-
return [partId, chapterId, lessonId];
305+
return depthA - depthB;
306+
});
207307
}
208308

209309
function getSlug(entry: CollectionEntryTutorial) {
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Largely taken from Astro logger implementation.
3+
*
4+
* @see https://github.com/withastro/astro/blob/c44f7f4babbb19350cd673241136bc974b012d51/packages/astro/src/core/logger/core.ts#L200
5+
*/
6+
7+
import { blue, bold, dim, red, yellow } from 'kleur/colors';
8+
9+
const dateTimeFormat = new Intl.DateTimeFormat([], {
10+
hour: '2-digit',
11+
minute: '2-digit',
12+
second: '2-digit',
13+
hour12: false,
14+
});
15+
16+
function getEventPrefix(level: 'info' | 'error' | 'warn', label: string) {
17+
const timestamp = `${dateTimeFormat.format(new Date())}`;
18+
const prefix = [];
19+
20+
if (level === 'error' || level === 'warn') {
21+
prefix.push(bold(timestamp));
22+
prefix.push(`[${level.toUpperCase()}]`);
23+
} else {
24+
prefix.push(timestamp);
25+
}
26+
27+
if (label) {
28+
prefix.push(`[${label}]`);
29+
}
30+
31+
if (level === 'error') {
32+
return red(prefix.join(' '));
33+
}
34+
if (level === 'warn') {
35+
return yellow(prefix.join(' '));
36+
}
37+
38+
if (prefix.length === 1) {
39+
return dim(prefix[0]);
40+
}
41+
return dim(prefix[0]) + ' ' + blue(prefix.splice(1).join(' '));
42+
}
43+
44+
export const logger = {
45+
warn(message: string) {
46+
console.log(getEventPrefix('warn', 'tutorialkit') + ' ' + message);
47+
},
48+
error(message: string) {
49+
console.error(getEventPrefix('error', 'tutorialkit') + ' ' + message);
50+
},
51+
info(message: string) {
52+
console.log(getEventPrefix('info', 'tutorialkit') + ' ' + message);
53+
},
54+
};

packages/astro/src/default/utils/nav.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ export function generateNavigationList(tutorial: Tutorial): NavList {
2222
});
2323
}
2424

25-
function objectToSortedArray<T extends Record<any, any>>(object: T): Array<T[keyof T]> {
25+
function objectToSortedArray<T extends Record<any, { order: number }>>(object: T): Array<T[keyof T]> {
2626
return Object.keys(object)
27-
.sort((a, b) => Number(a) - Number(b))
28-
.map((key) => object[key]);
27+
.map((key) => object[key] as T[keyof T])
28+
.sort((a, b) => a.order - b.order);
2929
}

packages/types/src/entities/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,25 @@ export interface LessonLink {
1717

1818
export interface Part {
1919
id: string;
20+
order: number;
2021
slug: string;
2122
data: PartSchema;
23+
firstChapterId?: string;
2224
chapters: Record<string, Chapter>;
2325
}
2426

2527
export interface Chapter {
2628
id: string;
29+
order: number;
2730
slug: string;
2831
data: ChapterSchema;
32+
firstLessonId?: string;
2933
lessons: Record<string, Lesson>;
3034
}
3135

3236
export interface Lesson<T = unknown> {
3337
id: string;
38+
order: number;
3439
data: LessonSchema;
3540
part: { id: string; title: string };
3641
chapter: { id: string; title: string };
@@ -46,5 +51,6 @@ export interface Lesson<T = unknown> {
4651

4752
export interface Tutorial {
4853
logoLink?: string;
54+
firstPartId?: string;
4955
parts: Record<string, Part>;
5056
}

packages/types/src/schemas/chapter.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { baseSchema } from './common.js';
33

44
export const chapterSchema = baseSchema.extend({
55
type: z.literal('chapter'),
6+
lessons: z.array(z.string()).optional(),
67
});
78

89
export type ChapterSchema = z.infer<typeof chapterSchema>;

packages/types/src/schemas/part.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { baseSchema } from './common.js';
33

44
export const partSchema = baseSchema.extend({
55
type: z.literal('part'),
6+
chapters: z.array(z.string()).optional(),
67
});
78

89
export type PartSchema = z.infer<typeof partSchema>;

packages/types/src/schemas/tutorial.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { webcontainerSchema } from './common.js';
44
export const tutorialSchema = webcontainerSchema.extend({
55
type: z.literal('tutorial'),
66
logoLink: z.string().optional(),
7+
parts: z.array(z.string()).optional(),
78
});
89

910
export type TutorialSchema = z.infer<typeof tutorialSchema>;

0 commit comments

Comments
 (0)