@tutorialkit-rb/astro 1.5.2-rb.0.1.0

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.
Files changed (63) hide show
  1. package/README.md +14 -0
  2. package/dist/default/components/DownloadButton.tsx +44 -0
  3. package/dist/default/components/HeadTags.astro +3 -0
  4. package/dist/default/components/LoginButton.tsx +55 -0
  5. package/dist/default/components/Logo.astro +30 -0
  6. package/dist/default/components/MainContainer.astro +86 -0
  7. package/dist/default/components/MetaTags.astro +44 -0
  8. package/dist/default/components/MobileContentToggle.astro +44 -0
  9. package/dist/default/components/NavCard.astro +23 -0
  10. package/dist/default/components/NavWrapper.tsx +11 -0
  11. package/dist/default/components/OpenInStackblitzLink.tsx +37 -0
  12. package/dist/default/components/PageLoadingIndicator.astro +66 -0
  13. package/dist/default/components/ResizablePanel.astro +247 -0
  14. package/dist/default/components/ThemeSwitch.tsx +24 -0
  15. package/dist/default/components/TopBar.astro +20 -0
  16. package/dist/default/components/TopBarWrapper.astro +30 -0
  17. package/dist/default/components/TutorialContent.astro +48 -0
  18. package/dist/default/components/WorkspacePanelWrapper.tsx +25 -0
  19. package/dist/default/components/setup.ts +20 -0
  20. package/dist/default/components/webcontainer.ts +46 -0
  21. package/dist/default/env-default.d.ts +19 -0
  22. package/dist/default/layouts/Layout.astro +98 -0
  23. package/dist/default/pages/[...slug].astro +39 -0
  24. package/dist/default/pages/index.astro +25 -0
  25. package/dist/default/stores/auth-store.ts +6 -0
  26. package/dist/default/stores/theme-store.ts +32 -0
  27. package/dist/default/stores/view-store.ts +5 -0
  28. package/dist/default/styles/base.css +11 -0
  29. package/dist/default/styles/markdown.css +400 -0
  30. package/dist/default/styles/panel.css +7 -0
  31. package/dist/default/styles/variables.css +396 -0
  32. package/dist/default/utils/constants.ts +6 -0
  33. package/dist/default/utils/content/files-ref.ts +25 -0
  34. package/dist/default/utils/content/squash.ts +37 -0
  35. package/dist/default/utils/content.ts +446 -0
  36. package/dist/default/utils/logger.ts +56 -0
  37. package/dist/default/utils/logo.ts +17 -0
  38. package/dist/default/utils/nav.ts +65 -0
  39. package/dist/default/utils/publicAsset.ts +27 -0
  40. package/dist/default/utils/routes.ts +34 -0
  41. package/dist/default/utils/url.ts +22 -0
  42. package/dist/default/utils/workspace.ts +31 -0
  43. package/dist/index.d.ts +57 -0
  44. package/dist/index.js +972 -0
  45. package/dist/integrations.d.ts +10 -0
  46. package/dist/remark/callouts.d.ts +3 -0
  47. package/dist/remark/import-file.d.ts +7 -0
  48. package/dist/remark/index.d.ts +2 -0
  49. package/dist/types.d.ts +9 -0
  50. package/dist/utils.d.ts +2 -0
  51. package/dist/vite-plugins/core.d.ts +2 -0
  52. package/dist/vite-plugins/css.d.ts +4 -0
  53. package/dist/vite-plugins/override-components.d.ts +78 -0
  54. package/dist/vite-plugins/store.d.ts +2 -0
  55. package/dist/webcontainer-files/cache.d.ts +21 -0
  56. package/dist/webcontainer-files/cache.spec.d.ts +1 -0
  57. package/dist/webcontainer-files/constants.d.ts +4 -0
  58. package/dist/webcontainer-files/filesmap.d.ts +38 -0
  59. package/dist/webcontainer-files/filesmap.spec.d.ts +1 -0
  60. package/dist/webcontainer-files/index.d.ts +8 -0
  61. package/dist/webcontainer-files/utils.d.ts +6 -0
  62. package/package.json +85 -0
  63. 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
+ }