@tutorialkit-rb/astro 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +14 -0
- package/dist/default/components/DownloadButton.tsx +44 -0
- package/dist/default/components/HeadTags.astro +3 -0
- package/dist/default/components/LoginButton.tsx +55 -0
- package/dist/default/components/Logo.astro +30 -0
- package/dist/default/components/MainContainer.astro +86 -0
- package/dist/default/components/MetaTags.astro +44 -0
- package/dist/default/components/MobileContentToggle.astro +44 -0
- package/dist/default/components/NavCard.astro +23 -0
- package/dist/default/components/NavWrapper.tsx +11 -0
- package/dist/default/components/OpenInStackblitzLink.tsx +37 -0
- package/dist/default/components/PageLoadingIndicator.astro +66 -0
- package/dist/default/components/ResizablePanel.astro +247 -0
- package/dist/default/components/ThemeSwitch.tsx +24 -0
- package/dist/default/components/TopBar.astro +20 -0
- package/dist/default/components/TopBarWrapper.astro +30 -0
- package/dist/default/components/TutorialContent.astro +48 -0
- package/dist/default/components/WorkspacePanelWrapper.tsx +25 -0
- package/dist/default/components/setup.ts +20 -0
- package/dist/default/components/webcontainer.ts +46 -0
- package/dist/default/env-default.d.ts +19 -0
- package/dist/default/layouts/Layout.astro +98 -0
- package/dist/default/pages/[...slug].astro +39 -0
- package/dist/default/pages/index.astro +25 -0
- package/dist/default/stores/auth-store.ts +6 -0
- package/dist/default/stores/theme-store.ts +32 -0
- package/dist/default/stores/view-store.ts +5 -0
- package/dist/default/styles/base.css +11 -0
- package/dist/default/styles/markdown.css +400 -0
- package/dist/default/styles/panel.css +7 -0
- package/dist/default/styles/variables.css +396 -0
- package/dist/default/utils/constants.ts +6 -0
- package/dist/default/utils/content/files-ref.ts +25 -0
- package/dist/default/utils/content/squash.ts +37 -0
- package/dist/default/utils/content.ts +446 -0
- package/dist/default/utils/logger.ts +56 -0
- package/dist/default/utils/logo.ts +17 -0
- package/dist/default/utils/nav.ts +65 -0
- package/dist/default/utils/publicAsset.ts +27 -0
- package/dist/default/utils/routes.ts +34 -0
- package/dist/default/utils/url.ts +22 -0
- package/dist/default/utils/workspace.ts +31 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +972 -0
- package/dist/integrations.d.ts +10 -0
- package/dist/remark/callouts.d.ts +3 -0
- package/dist/remark/import-file.d.ts +7 -0
- package/dist/remark/index.d.ts +2 -0
- package/dist/types.d.ts +9 -0
- package/dist/utils.d.ts +2 -0
- package/dist/vite-plugins/core.d.ts +2 -0
- package/dist/vite-plugins/css.d.ts +4 -0
- package/dist/vite-plugins/override-components.d.ts +78 -0
- package/dist/vite-plugins/store.d.ts +2 -0
- package/dist/webcontainer-files/cache.d.ts +21 -0
- package/dist/webcontainer-files/cache.spec.d.ts +1 -0
- package/dist/webcontainer-files/constants.d.ts +4 -0
- package/dist/webcontainer-files/filesmap.d.ts +38 -0
- package/dist/webcontainer-files/filesmap.spec.d.ts +1 -0
- package/dist/webcontainer-files/index.d.ts +8 -0
- package/dist/webcontainer-files/utils.d.ts +6 -0
- package/package.json +80 -0
- package/types.d.ts +12 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type {
|
|
3
|
+
Chapter,
|
|
4
|
+
ChapterSchema,
|
|
5
|
+
Lesson,
|
|
6
|
+
LessonSchema,
|
|
7
|
+
Part,
|
|
8
|
+
PartSchema,
|
|
9
|
+
Tutorial,
|
|
10
|
+
TutorialSchema,
|
|
11
|
+
} from '@tutorialkit-rb/types';
|
|
12
|
+
import { interpolateString, DEFAULT_LOCALIZATION } from '@tutorialkit-rb/types';
|
|
13
|
+
import { getCollection } from 'astro:content';
|
|
14
|
+
import { getFilesRefList } from './content/files-ref';
|
|
15
|
+
import { squash } from './content/squash.js';
|
|
16
|
+
import { logger } from './logger';
|
|
17
|
+
import { joinPaths } from './url';
|
|
18
|
+
|
|
19
|
+
export async function getTutorial(): Promise<Tutorial> {
|
|
20
|
+
const collection = sortCollection(await getCollection('tutorial'));
|
|
21
|
+
|
|
22
|
+
const { tutorial, tutorialMetaData } = await parseCollection(collection);
|
|
23
|
+
assertTutorialStructure(tutorial);
|
|
24
|
+
sortTutorialLessons(tutorial, tutorialMetaData);
|
|
25
|
+
|
|
26
|
+
// find orphans discard them and print warnings
|
|
27
|
+
for (const partId in tutorial.parts) {
|
|
28
|
+
const part = tutorial.parts[partId];
|
|
29
|
+
|
|
30
|
+
if (part.order === -1) {
|
|
31
|
+
delete tutorial.parts[partId];
|
|
32
|
+
logger.warn(
|
|
33
|
+
`An order was specified for the parts of the tutorial but '${partId}' is not included so it won't be visible.`,
|
|
34
|
+
);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const chapterId in part.chapters) {
|
|
39
|
+
const chapter = part.chapters[chapterId];
|
|
40
|
+
|
|
41
|
+
if (chapter.order === -1) {
|
|
42
|
+
delete part.chapters[chapterId];
|
|
43
|
+
logger.warn(
|
|
44
|
+
`An order was specified for part '${partId}' but chapter '${chapterId}' is not included, so it won't be visible.`,
|
|
45
|
+
);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const chapterLessons = tutorial.lessons.filter((l) => l.chapter?.id === chapterId && l.part?.id === partId);
|
|
50
|
+
|
|
51
|
+
for (const lesson of chapterLessons) {
|
|
52
|
+
if (lesson.order === -1) {
|
|
53
|
+
logger.warn(
|
|
54
|
+
`An order was specified for chapter '${chapterId}' but lesson '${lesson.id}' is not included, so it won't be visible.`,
|
|
55
|
+
);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// removed orphaned lessons
|
|
63
|
+
tutorial.lessons = tutorial.lessons.filter((lesson) => lesson.order > -1);
|
|
64
|
+
|
|
65
|
+
const baseURL = import.meta.env.BASE_URL;
|
|
66
|
+
|
|
67
|
+
// now we link all lessons together and apply metadata inheritance
|
|
68
|
+
for (const [i, lesson] of tutorial.lessons.entries()) {
|
|
69
|
+
const prevLesson = i > 0 ? tutorial.lessons.at(i - 1) : undefined;
|
|
70
|
+
const nextLesson = tutorial.lessons.at(i + 1);
|
|
71
|
+
|
|
72
|
+
// order for metadata: lesson <- chapter (optional) <- part (optional) <- tutorial
|
|
73
|
+
const sources: (Lesson['data'] | Chapter['data'] | Part['data'] | TutorialSchema)[] = [lesson.data];
|
|
74
|
+
|
|
75
|
+
if (lesson.part && lesson.chapter) {
|
|
76
|
+
sources.push(tutorial.parts[lesson.part.id].chapters[lesson.chapter.id].data);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (lesson.part) {
|
|
80
|
+
sources.push(tutorial.parts[lesson.part.id].data);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
sources.push(tutorialMetaData);
|
|
84
|
+
|
|
85
|
+
lesson.data = {
|
|
86
|
+
...lesson.data,
|
|
87
|
+
...squash(sources, [
|
|
88
|
+
'mainCommand',
|
|
89
|
+
'prepareCommands',
|
|
90
|
+
'previews',
|
|
91
|
+
'autoReload',
|
|
92
|
+
'template',
|
|
93
|
+
'terminal',
|
|
94
|
+
'editor',
|
|
95
|
+
'focus',
|
|
96
|
+
'i18n',
|
|
97
|
+
'meta',
|
|
98
|
+
'editPageLink',
|
|
99
|
+
'openInStackBlitz',
|
|
100
|
+
'downloadAsZip',
|
|
101
|
+
'filesystem',
|
|
102
|
+
]),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (prevLesson) {
|
|
106
|
+
const partSlug = prevLesson.part && tutorial.parts[prevLesson.part.id].slug;
|
|
107
|
+
const chapterSlug =
|
|
108
|
+
prevLesson.part &&
|
|
109
|
+
prevLesson.chapter &&
|
|
110
|
+
tutorial.parts[prevLesson.part.id].chapters[prevLesson.chapter.id].slug;
|
|
111
|
+
|
|
112
|
+
const slug = [partSlug, chapterSlug, prevLesson.slug].filter(Boolean).join('/');
|
|
113
|
+
|
|
114
|
+
lesson.prev = {
|
|
115
|
+
title: prevLesson.data.title,
|
|
116
|
+
href: joinPaths(baseURL, `/${slug}`),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (nextLesson) {
|
|
121
|
+
const partSlug = nextLesson.part && tutorial.parts[nextLesson.part.id].slug;
|
|
122
|
+
const chapterSlug =
|
|
123
|
+
nextLesson.part &&
|
|
124
|
+
nextLesson.chapter &&
|
|
125
|
+
tutorial.parts[nextLesson.part.id].chapters[nextLesson.chapter.id].slug;
|
|
126
|
+
|
|
127
|
+
const slug = [partSlug, chapterSlug, nextLesson.slug].filter(Boolean).join('/');
|
|
128
|
+
|
|
129
|
+
lesson.next = {
|
|
130
|
+
title: nextLesson.data.title,
|
|
131
|
+
href: joinPaths(baseURL, `/${slug}`),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (lesson.data.editPageLink && typeof lesson.data.editPageLink === 'string') {
|
|
136
|
+
lesson.editPageLink = interpolateString(lesson.data.editPageLink, { path: lesson.filepath });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return tutorial;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function parseCollection(collection: CollectionEntryTutorial[]) {
|
|
144
|
+
const tutorial: Tutorial = {
|
|
145
|
+
parts: {},
|
|
146
|
+
lessons: [],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let tutorialMetaData: TutorialSchema | undefined;
|
|
150
|
+
|
|
151
|
+
for (const entry of collection) {
|
|
152
|
+
const { id, data } = entry;
|
|
153
|
+
const { type } = data;
|
|
154
|
+
|
|
155
|
+
const { partId, chapterId, lessonId } = resolveIds(id, type);
|
|
156
|
+
|
|
157
|
+
if (type === 'tutorial') {
|
|
158
|
+
tutorialMetaData = data;
|
|
159
|
+
|
|
160
|
+
// default template if not specified
|
|
161
|
+
tutorialMetaData.template ??= 'default';
|
|
162
|
+
tutorialMetaData.i18n = Object.assign({ ...DEFAULT_LOCALIZATION }, tutorialMetaData.i18n);
|
|
163
|
+
tutorialMetaData.openInStackBlitz ??= true;
|
|
164
|
+
tutorialMetaData.downloadAsZip ??= false;
|
|
165
|
+
|
|
166
|
+
tutorial.logoLink = data.logoLink;
|
|
167
|
+
} else if (type === 'part') {
|
|
168
|
+
if (!partId) {
|
|
169
|
+
throw new Error('Part missing id');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
tutorial.parts[partId] = {
|
|
173
|
+
id: partId,
|
|
174
|
+
order: -1,
|
|
175
|
+
data,
|
|
176
|
+
slug: getSlug(entry),
|
|
177
|
+
chapters: {},
|
|
178
|
+
};
|
|
179
|
+
} else if (type === 'chapter') {
|
|
180
|
+
if (!chapterId || !partId) {
|
|
181
|
+
throw new Error(`Chapter missing ids: [${partId || null}, ${chapterId || null}]`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!tutorial.parts[partId]) {
|
|
185
|
+
throw new Error(`Could not find part '${partId}'`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
tutorial.parts[partId].chapters[chapterId] = {
|
|
189
|
+
id: chapterId,
|
|
190
|
+
order: -1,
|
|
191
|
+
data,
|
|
192
|
+
slug: getSlug(entry),
|
|
193
|
+
};
|
|
194
|
+
} else if (type === 'lesson') {
|
|
195
|
+
if (!lessonId) {
|
|
196
|
+
throw new Error('Lesson missing id');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { Content } = await entry.render();
|
|
200
|
+
|
|
201
|
+
const lessonDir = path.dirname(entry.id);
|
|
202
|
+
const filesDir = path.join(lessonDir, '_files');
|
|
203
|
+
const solutionDir = path.join(lessonDir, '_solution');
|
|
204
|
+
|
|
205
|
+
const files = await getFilesRefList(filesDir);
|
|
206
|
+
const solution = await getFilesRefList(solutionDir);
|
|
207
|
+
|
|
208
|
+
const lesson: Lesson = {
|
|
209
|
+
data,
|
|
210
|
+
id: lessonId,
|
|
211
|
+
filepath: id,
|
|
212
|
+
order: -1,
|
|
213
|
+
Markdown: Content,
|
|
214
|
+
slug: getSlug(entry),
|
|
215
|
+
files,
|
|
216
|
+
solution,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (partId) {
|
|
220
|
+
if (!tutorial.parts[partId]) {
|
|
221
|
+
throw new Error(`Could not find part '${partId}'`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lesson.part = {
|
|
225
|
+
id: partId,
|
|
226
|
+
title: tutorial.parts[partId].data.title,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (partId && chapterId) {
|
|
231
|
+
if (!tutorial.parts[partId].chapters[chapterId]) {
|
|
232
|
+
throw new Error(`Could not find chapter '${chapterId}'`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
lesson.chapter = {
|
|
236
|
+
id: chapterId,
|
|
237
|
+
title: tutorial.parts[partId].chapters[chapterId].data.title,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
tutorial.lessons.push(lesson);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!tutorialMetaData) {
|
|
246
|
+
throw new Error(`Could not find tutorial 'meta.md' file`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { tutorial, tutorialMetaData };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getOrder(
|
|
253
|
+
order: string[] | undefined,
|
|
254
|
+
fallbackSourceForOrder: Record<string, Part | Chapter> | Lesson['id'][],
|
|
255
|
+
): string[] {
|
|
256
|
+
if (order) {
|
|
257
|
+
return order;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const keys = Array.isArray(fallbackSourceForOrder)
|
|
261
|
+
? [...fallbackSourceForOrder]
|
|
262
|
+
: Object.keys(fallbackSourceForOrder);
|
|
263
|
+
|
|
264
|
+
// default to an order based on having each folder prefixed by their order: `1-foo`, `2-bar`, etc.
|
|
265
|
+
return keys.sort((a, b) => {
|
|
266
|
+
const numA = parseInt(a, 10);
|
|
267
|
+
const numB = parseInt(b, 10);
|
|
268
|
+
|
|
269
|
+
return numA - numB;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sortCollection(collection: CollectionEntryTutorial[]) {
|
|
274
|
+
return collection.sort((a, b) => {
|
|
275
|
+
const depthA = a.id.split('/').length;
|
|
276
|
+
const depthB = b.id.split('/').length;
|
|
277
|
+
|
|
278
|
+
return depthA - depthB;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function getSlug(entry: CollectionEntryTutorial) {
|
|
283
|
+
let slug: string = entry.slug;
|
|
284
|
+
|
|
285
|
+
if (entry.slug.includes('/')) {
|
|
286
|
+
const parts = entry.slug.split('/');
|
|
287
|
+
const _slug = parts.at(-2);
|
|
288
|
+
|
|
289
|
+
if (!_slug) {
|
|
290
|
+
throw new Error('Invalid slug');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
slug = _slug;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return slug;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function resolveIds(
|
|
300
|
+
id: string,
|
|
301
|
+
type: CollectionEntryTutorial['data']['type'],
|
|
302
|
+
): { partId?: string; chapterId?: string; lessonId?: string } {
|
|
303
|
+
const parts = id.split('/');
|
|
304
|
+
|
|
305
|
+
if (type === 'tutorial') {
|
|
306
|
+
return {};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (type === 'part') {
|
|
310
|
+
return {
|
|
311
|
+
partId: parts[0],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (type === 'chapter') {
|
|
316
|
+
return {
|
|
317
|
+
partId: parts[0],
|
|
318
|
+
chapterId: parts[1],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Supported schemes for lessons are are:
|
|
324
|
+
* - 'lesson-id/content.md'
|
|
325
|
+
* - 'part-id/lesson-id/content.md'
|
|
326
|
+
* - 'part-id/chapter-id/lesson-id/content.md'
|
|
327
|
+
*/
|
|
328
|
+
if (parts.length === 2) {
|
|
329
|
+
return {
|
|
330
|
+
lessonId: parts[0],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (parts.length === 3) {
|
|
335
|
+
return {
|
|
336
|
+
partId: parts[0],
|
|
337
|
+
lessonId: parts[1],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
partId: parts[0],
|
|
343
|
+
chapterId: parts[1],
|
|
344
|
+
lessonId: parts[2],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function assertTutorialStructure(tutorial: Tutorial) {
|
|
349
|
+
// verify that parts and lessons are not mixed in tutorial
|
|
350
|
+
if (Object.keys(tutorial.parts).length !== 0 && tutorial.lessons.some((lesson) => !lesson.part)) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
'Cannot mix lessons and parts in a tutorial. Either remove the parts or move root level lessons into a part.',
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// verify that chapters and lessons are not mixed in a single part
|
|
357
|
+
for (const part of Object.values(tutorial.parts)) {
|
|
358
|
+
if (Object.keys(part.chapters).length === 0) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (tutorial.lessons.some((lesson) => lesson.part?.id === part.id && !lesson.chapter)) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`Cannot mix lessons and chapters in a part. Either remove the chapter from ${part.id} or move the lessons into a chapter.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function sortTutorialLessons(tutorial: Tutorial, metadata: TutorialSchema) {
|
|
371
|
+
const lessonIds = tutorial.lessons.map((lesson) => lesson.id);
|
|
372
|
+
|
|
373
|
+
// lesson ID alone does not make a lesson unique - combination of lessonId + chapterId + partId does
|
|
374
|
+
const lessonOrder: { lessonId: Lesson['id']; chapterId?: Chapter['id']; partId?: Part['id'] }[] = [];
|
|
375
|
+
|
|
376
|
+
const lessonsInRoot = Object.keys(tutorial.parts).length === 0;
|
|
377
|
+
|
|
378
|
+
// if lessons in root, sort by tutorial.lessons and metadata.lessons
|
|
379
|
+
if (lessonsInRoot) {
|
|
380
|
+
lessonOrder.push(...getOrder(metadata.lessons, lessonIds).map((lessonId) => ({ lessonId })));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// if no lessons in root, sort by parts and their possible chapters
|
|
384
|
+
if (!lessonsInRoot) {
|
|
385
|
+
for (const [partOrder, partId] of getOrder(metadata.parts, tutorial.parts).entries()) {
|
|
386
|
+
const part = tutorial.parts[partId];
|
|
387
|
+
|
|
388
|
+
if (!part) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
part.order = partOrder;
|
|
393
|
+
|
|
394
|
+
const partLessons = tutorial.lessons
|
|
395
|
+
.filter((lesson) => lesson.chapter == null && lesson.part?.id === partId)
|
|
396
|
+
.map((lesson) => lesson.id);
|
|
397
|
+
|
|
398
|
+
// all lessons are in part, no chapters
|
|
399
|
+
if (partLessons.length) {
|
|
400
|
+
lessonOrder.push(...getOrder(part.data.lessons, partLessons).map((lessonId) => ({ lessonId, partId })));
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// lessons in chapters
|
|
405
|
+
for (const [chapterOrder, chapterId] of getOrder(part.data.chapters, part.chapters).entries()) {
|
|
406
|
+
const chapter = part.chapters[chapterId];
|
|
407
|
+
|
|
408
|
+
if (!chapter) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
chapter.order = chapterOrder;
|
|
413
|
+
|
|
414
|
+
const chapterLessons = tutorial.lessons
|
|
415
|
+
.filter((lesson) => lesson.chapter?.id === chapter.id && lesson.part?.id === partId)
|
|
416
|
+
.map((lesson) => lesson.id);
|
|
417
|
+
|
|
418
|
+
const chapterLessonOrder = getOrder(chapter.data.lessons, chapterLessons);
|
|
419
|
+
|
|
420
|
+
lessonOrder.push(...chapterLessonOrder.map((lessonId) => ({ lessonId, partId, chapterId })));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// finally apply overall order for lessons
|
|
426
|
+
for (const lesson of tutorial.lessons) {
|
|
427
|
+
lesson.order = lessonOrder.findIndex(
|
|
428
|
+
(l) => l.lessonId === lesson.id && l.chapterId === lesson.chapter?.id && l.partId === lesson.part?.id,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
tutorial.lessons.sort((a, b) => a.order - b.order);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface CollectionEntryTutorial {
|
|
436
|
+
id: string;
|
|
437
|
+
slug: string;
|
|
438
|
+
body: string;
|
|
439
|
+
collection: 'tutorial';
|
|
440
|
+
data: TutorialSchema | PartSchema | ChapterSchema | LessonSchema;
|
|
441
|
+
render(): Promise<{
|
|
442
|
+
Content: import('astro').MarkdownInstance<Record<any, any>>['Content'];
|
|
443
|
+
headings: import('astro').MarkdownHeading[];
|
|
444
|
+
remarkPluginFrontmatter: Record<string, any>;
|
|
445
|
+
}>;
|
|
446
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
|
|
35
|
+
if (level === 'warn') {
|
|
36
|
+
return yellow(prefix.join(' '));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (prefix.length === 1) {
|
|
40
|
+
return dim(prefix[0]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return dim(prefix[0]) + ' ' + blue(prefix.splice(1).join(' '));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const logger = {
|
|
47
|
+
warn(message: string) {
|
|
48
|
+
console.log(getEventPrefix('warn', 'tutorialkit') + ' ' + message);
|
|
49
|
+
},
|
|
50
|
+
error(message: string) {
|
|
51
|
+
console.error(getEventPrefix('error', 'tutorialkit') + ' ' + message);
|
|
52
|
+
},
|
|
53
|
+
info(message: string) {
|
|
54
|
+
console.log(getEventPrefix('info', 'tutorialkit') + ' ' + message);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { LOGO_EXTENSIONS } from './constants';
|
|
2
|
+
import { readPublicAsset } from './publicAsset';
|
|
3
|
+
|
|
4
|
+
export function readLogoFile(logoPrefix: string = 'logo', absolute?: boolean) {
|
|
5
|
+
let logo;
|
|
6
|
+
|
|
7
|
+
for (const logoExt of LOGO_EXTENSIONS) {
|
|
8
|
+
const logoFilename = `${logoPrefix}.${logoExt}`;
|
|
9
|
+
logo = readPublicAsset(logoFilename, absolute);
|
|
10
|
+
|
|
11
|
+
if (logo) {
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return logo;
|
|
17
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Tutorial, NavList, Part, Chapter } from '@tutorialkit-rb/types';
|
|
2
|
+
import { joinPaths } from './url';
|
|
3
|
+
|
|
4
|
+
type NavItem = Required<Omit<NavList[number], 'href'>>;
|
|
5
|
+
|
|
6
|
+
export function generateNavigationList(tutorial: Tutorial, baseURL: string): NavList {
|
|
7
|
+
const list: NavList = [];
|
|
8
|
+
|
|
9
|
+
// caches for higher level items
|
|
10
|
+
const chapterItems = new Map<Chapter['id'] | undefined, NavItem>();
|
|
11
|
+
const partItems = new Map<Part['id'] | undefined, NavItem>();
|
|
12
|
+
|
|
13
|
+
for (const lesson of tutorial.lessons) {
|
|
14
|
+
const part = lesson.part && tutorial.parts[lesson.part.id];
|
|
15
|
+
const chapter = lesson.chapter && part && part.chapters[lesson.chapter.id];
|
|
16
|
+
|
|
17
|
+
let partItem = partItems.get(part?.id);
|
|
18
|
+
let chapterItem = chapterItems.get(chapter?.id);
|
|
19
|
+
|
|
20
|
+
if (part && !partItem) {
|
|
21
|
+
partItem = {
|
|
22
|
+
id: part.id,
|
|
23
|
+
title: part.data.title,
|
|
24
|
+
type: 'part',
|
|
25
|
+
sections: [],
|
|
26
|
+
};
|
|
27
|
+
list.push(partItem);
|
|
28
|
+
partItems.set(part.id, partItem);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (chapter && !chapterItem) {
|
|
32
|
+
if (!partItem) {
|
|
33
|
+
throw new Error('Failed to resolve part');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
chapterItem = {
|
|
37
|
+
id: chapter.id,
|
|
38
|
+
title: chapter.data.title,
|
|
39
|
+
type: 'chapter',
|
|
40
|
+
sections: [],
|
|
41
|
+
};
|
|
42
|
+
chapterItems.set(chapter.id, chapterItem);
|
|
43
|
+
partItem.sections.push(chapterItem);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const slug = [part?.slug, chapter?.slug, lesson.slug].filter(Boolean).join('/');
|
|
47
|
+
|
|
48
|
+
const lessonItem: NavList[number] = {
|
|
49
|
+
id: lesson.id,
|
|
50
|
+
title: lesson.data.title,
|
|
51
|
+
type: 'lesson',
|
|
52
|
+
href: joinPaths(baseURL, `/${slug}`),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (chapterItem) {
|
|
56
|
+
chapterItem.sections.push(lessonItem);
|
|
57
|
+
} else if (partItem) {
|
|
58
|
+
partItem.sections.push(lessonItem);
|
|
59
|
+
} else {
|
|
60
|
+
list.push(lessonItem);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return list;
|
|
65
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { joinPaths } from './url';
|
|
4
|
+
|
|
5
|
+
export function readPublicAsset(filename: string, absolute?: boolean) {
|
|
6
|
+
let asset;
|
|
7
|
+
const exists = fs.existsSync(path.join('public', filename));
|
|
8
|
+
|
|
9
|
+
if (!exists) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
asset = joinPaths(import.meta.env.BASE_URL, filename);
|
|
14
|
+
|
|
15
|
+
if (absolute) {
|
|
16
|
+
const site = import.meta.env.SITE;
|
|
17
|
+
|
|
18
|
+
if (!site) {
|
|
19
|
+
// the SITE env variable inherits the value from Astro.site configuration
|
|
20
|
+
console.warn('Trying to compute an absolute file URL but Astro.site is not set.');
|
|
21
|
+
} else {
|
|
22
|
+
asset = joinPaths(site, asset);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return asset;
|
|
27
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Lesson } from '@tutorialkit-rb/types';
|
|
2
|
+
import type { GetStaticPaths, GetStaticPathsItem } from 'astro';
|
|
3
|
+
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
|
|
4
|
+
import { getTutorial } from './content';
|
|
5
|
+
import { generateNavigationList } from './nav';
|
|
6
|
+
|
|
7
|
+
export async function generateStaticRoutes() {
|
|
8
|
+
const tutorial = await getTutorial();
|
|
9
|
+
|
|
10
|
+
const routes = [];
|
|
11
|
+
const lessons = Object.values(tutorial.lessons);
|
|
12
|
+
|
|
13
|
+
for (const lesson of lessons) {
|
|
14
|
+
const part = lesson.part && tutorial.parts[lesson.part.id];
|
|
15
|
+
const chapter = lesson.chapter && part?.chapters[lesson.chapter.id];
|
|
16
|
+
|
|
17
|
+
const slug = [part?.slug, chapter?.slug, lesson.slug].filter(Boolean).join('/');
|
|
18
|
+
const title = [lesson.part?.title, lesson.chapter?.title, lesson.data.title].filter(Boolean).join(' / ');
|
|
19
|
+
|
|
20
|
+
routes.push({
|
|
21
|
+
params: {
|
|
22
|
+
slug: `/${slug}`,
|
|
23
|
+
},
|
|
24
|
+
props: {
|
|
25
|
+
title,
|
|
26
|
+
lesson: lesson as Lesson<AstroComponentFactory>,
|
|
27
|
+
logoLink: tutorial.logoLink,
|
|
28
|
+
navList: generateNavigationList(tutorial, import.meta.env.BASE_URL),
|
|
29
|
+
},
|
|
30
|
+
} satisfies GetStaticPathsItem);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return routes satisfies ReturnType<GetStaticPaths>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function joinPaths(basePath: string, ...paths: string[]): string {
|
|
2
|
+
let result = basePath || '/';
|
|
3
|
+
|
|
4
|
+
for (const subpath of paths) {
|
|
5
|
+
if (subpath.length === 0) {
|
|
6
|
+
continue;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const resultEndsWithSlash = result.endsWith('/');
|
|
10
|
+
const subpathStartsWithSlash = subpath.startsWith('/');
|
|
11
|
+
|
|
12
|
+
if (resultEndsWithSlash && subpathStartsWithSlash) {
|
|
13
|
+
result += subpath.slice(1);
|
|
14
|
+
} else if (resultEndsWithSlash || subpathStartsWithSlash) {
|
|
15
|
+
result += subpath;
|
|
16
|
+
} else {
|
|
17
|
+
result += `/${subpath}`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Lesson } from '@tutorialkit-rb/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests if the provided lesson needs to show the workspace panel or not.
|
|
5
|
+
*
|
|
6
|
+
* @param lesson The lesson to check the workspace for.
|
|
7
|
+
*/
|
|
8
|
+
export function hasWorkspace(lesson: Lesson) {
|
|
9
|
+
if (lesson.data.editor !== false) {
|
|
10
|
+
// we have a workspace if the editor is not hidden
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (lesson.data.previews !== false) {
|
|
15
|
+
// we have a workspace if the previews are shown
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (lesson.data.terminal === false) {
|
|
20
|
+
// if the value is explicitly false, it will render nothing
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (lesson.data.terminal === true || !Array.isArray(lesson.data.terminal?.panels)) {
|
|
25
|
+
// if the value is explicitly true, or `panels` is not an array, we have to render the terminal
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// we have a workspace if we have more than 0 terminal panels
|
|
30
|
+
return lesson.data.terminal.panels.length > 0;
|
|
31
|
+
}
|