@tutorialkit-rb/runtime 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 (46) hide show
  1. package/README.md +18 -0
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +2 -0
  4. package/dist/lesson-files.d.ts +22 -0
  5. package/dist/lesson-files.js +126 -0
  6. package/dist/store/editor.d.ts +29 -0
  7. package/dist/store/editor.js +127 -0
  8. package/dist/store/index.d.ts +113 -0
  9. package/dist/store/index.js +316 -0
  10. package/dist/store/previews.d.ts +19 -0
  11. package/dist/store/previews.js +80 -0
  12. package/dist/store/terminal.d.ts +24 -0
  13. package/dist/store/terminal.js +128 -0
  14. package/dist/store/tutorial-runner.d.ts +147 -0
  15. package/dist/store/tutorial-runner.js +564 -0
  16. package/dist/tasks.d.ts +22 -0
  17. package/dist/tasks.js +26 -0
  18. package/dist/utils/multi-counter.d.ts +5 -0
  19. package/dist/utils/multi-counter.js +19 -0
  20. package/dist/utils/promises.d.ts +8 -0
  21. package/dist/utils/promises.js +29 -0
  22. package/dist/utils/support.d.ts +1 -0
  23. package/dist/utils/support.js +23 -0
  24. package/dist/utils/terminal.d.ts +17 -0
  25. package/dist/utils/terminal.js +13 -0
  26. package/dist/webcontainer/command.d.ts +28 -0
  27. package/dist/webcontainer/command.js +67 -0
  28. package/dist/webcontainer/editor-config.d.ts +12 -0
  29. package/dist/webcontainer/editor-config.js +60 -0
  30. package/dist/webcontainer/index.d.ts +4 -0
  31. package/dist/webcontainer/index.js +4 -0
  32. package/dist/webcontainer/on-demand-boot.d.ts +15 -0
  33. package/dist/webcontainer/on-demand-boot.js +39 -0
  34. package/dist/webcontainer/port-info.d.ts +6 -0
  35. package/dist/webcontainer/port-info.js +10 -0
  36. package/dist/webcontainer/preview-info.d.ts +21 -0
  37. package/dist/webcontainer/preview-info.js +56 -0
  38. package/dist/webcontainer/shell.d.ts +14 -0
  39. package/dist/webcontainer/shell.js +46 -0
  40. package/dist/webcontainer/steps.d.ts +15 -0
  41. package/dist/webcontainer/steps.js +38 -0
  42. package/dist/webcontainer/terminal-config.d.ts +59 -0
  43. package/dist/webcontainer/terminal-config.js +230 -0
  44. package/dist/webcontainer/utils/files.d.ts +10 -0
  45. package/dist/webcontainer/utils/files.js +76 -0
  46. package/package.json +53 -0
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @tutorialkit/runtime
2
+
3
+ A wrapper around the **[WebContainer API][webcontainer-api]** focused on providing the right abstractions to let you focus on building highly interactive tutorials.
4
+
5
+ The runtime exposes the following:
6
+
7
+ - `TutorialStore`: A store to manage your tutorial content in WebContainer and in your components.
8
+
9
+ Only a single instance of `TutorialStore` should be created in your application and its lifetime is bound by the lifetime of the WebContainer instance.
10
+
11
+ ## License
12
+
13
+ MIT
14
+
15
+ Copyright (c) 2024–present [StackBlitz][stackblitz]
16
+
17
+ [stackblitz]: https://stackblitz.com/
18
+ [webcontainer-api]: https://webcontainers.io
@@ -0,0 +1,3 @@
1
+ export type { Command, Commands, PreviewInfo, Step, Steps } from './webcontainer/index.js';
2
+ export { safeBoot } from './webcontainer/index.js';
3
+ export { TutorialStore } from './store/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { safeBoot } from './webcontainer/index.js';
2
+ export { TutorialStore } from './store/index.js';
@@ -0,0 +1,22 @@
1
+ import type { Files, Lesson } from '@tutorialkit-rb/types';
2
+ type InvalidationResult = {
3
+ type: 'template' | 'files' | 'solution';
4
+ files: Files;
5
+ } | {
6
+ type: 'none';
7
+ };
8
+ export declare class LessonFilesFetcher {
9
+ private _basePathname;
10
+ private _map;
11
+ private _templateLoadTask?;
12
+ private _templateLoaded;
13
+ constructor(_basePathname?: string);
14
+ invalidate(filesRef: string): Promise<InvalidationResult>;
15
+ getLessonTemplate(lesson: Lesson): Promise<Files>;
16
+ getLessonFiles(lesson: Lesson): Promise<Files>;
17
+ getLessonSolution(lesson: Lesson): Promise<Files>;
18
+ private _fetchTemplate;
19
+ private _getFilesFromFilesRefList;
20
+ private _fetchFiles;
21
+ }
22
+ export {};
@@ -0,0 +1,126 @@
1
+ import { newTask } from './tasks.js';
2
+ import { wait } from './utils/promises.js';
3
+ export class LessonFilesFetcher {
4
+ _basePathname;
5
+ _map = new Map();
6
+ _templateLoadTask;
7
+ _templateLoaded;
8
+ constructor(_basePathname = '/') {
9
+ this._basePathname = _basePathname;
10
+ if (!this._basePathname.endsWith('/')) {
11
+ this._basePathname = this._basePathname + '/';
12
+ }
13
+ }
14
+ async invalidate(filesRef) {
15
+ if (!this._map.has(filesRef)) {
16
+ return { type: 'none' };
17
+ }
18
+ const type = getTypeFromFilesRef(filesRef);
19
+ let files;
20
+ if (this._templateLoaded === filesRef) {
21
+ files = await this._fetchTemplate(filesRef).promise;
22
+ }
23
+ else {
24
+ files = await this._fetchFiles(filesRef);
25
+ }
26
+ return {
27
+ type,
28
+ files,
29
+ };
30
+ }
31
+ async getLessonTemplate(lesson) {
32
+ const templatePathname = `template-${lesson.data.template}.json`;
33
+ if (this._map.has(templatePathname)) {
34
+ return this._map.get(templatePathname);
35
+ }
36
+ if (this._templateLoadTask && this._templateLoaded === templatePathname) {
37
+ return this._templateLoadTask.promise;
38
+ }
39
+ const task = this._fetchTemplate(templatePathname);
40
+ return task.promise;
41
+ }
42
+ getLessonFiles(lesson) {
43
+ return this._getFilesFromFilesRefList(lesson.files);
44
+ }
45
+ getLessonSolution(lesson) {
46
+ return this._getFilesFromFilesRefList(lesson.solution);
47
+ }
48
+ _fetchTemplate(templatePathname) {
49
+ this._templateLoadTask?.cancel();
50
+ const task = newTask(async (signal) => {
51
+ const response = await fetch(`${this._basePathname}${templatePathname}`, { signal });
52
+ if (!response.ok) {
53
+ throw new Error(`Failed to fetch: status ${response.status}`);
54
+ }
55
+ const body = convertToFiles(await response.json());
56
+ this._map.set(templatePathname, body);
57
+ signal.throwIfAborted();
58
+ return body;
59
+ });
60
+ this._templateLoadTask = task;
61
+ this._templateLoaded = templatePathname;
62
+ return task;
63
+ }
64
+ async _getFilesFromFilesRefList(filesRefList) {
65
+ // the ref does not have any content
66
+ if (filesRefList[1].length === 0) {
67
+ return {};
68
+ }
69
+ const pathname = filesRefList[0];
70
+ if (this._map.has(pathname)) {
71
+ return this._map.get(pathname);
72
+ }
73
+ const promise = this._fetchFiles(pathname);
74
+ return promise;
75
+ }
76
+ async _fetchFiles(pathname) {
77
+ let retry = 2;
78
+ while (true) {
79
+ try {
80
+ const response = await fetch(`${this._basePathname}${pathname}`);
81
+ if (!response.ok) {
82
+ throw new Error(`Failed to fetch ${pathname}: ${response.status} ${response.statusText}`);
83
+ }
84
+ const body = convertToFiles(await response.json());
85
+ this._map.set(pathname, body);
86
+ return body;
87
+ }
88
+ catch (error) {
89
+ if (retry <= 0) {
90
+ console.error(`Failed to fetch ${pathname} after 3 attempts.`);
91
+ console.error(error);
92
+ return {};
93
+ }
94
+ }
95
+ retry -= 1;
96
+ await wait(1000);
97
+ }
98
+ }
99
+ }
100
+ function convertToFiles(json) {
101
+ const result = {};
102
+ if (typeof json !== 'object') {
103
+ return result;
104
+ }
105
+ for (const property in json) {
106
+ const value = json[property];
107
+ let transformedValue;
108
+ if (typeof value === 'object') {
109
+ transformedValue = Uint8Array.from(atob(value.base64), (char) => char.charCodeAt(0));
110
+ }
111
+ else {
112
+ transformedValue = value;
113
+ }
114
+ result[property] = transformedValue;
115
+ }
116
+ return result;
117
+ }
118
+ function getTypeFromFilesRef(filesRef) {
119
+ if (filesRef.startsWith('template-')) {
120
+ return 'template';
121
+ }
122
+ if (filesRef.endsWith('files.json')) {
123
+ return 'files';
124
+ }
125
+ return 'solution';
126
+ }
@@ -0,0 +1,29 @@
1
+ import type { FilesRefList, Files, EditorSchema, FileDescriptor } from '@tutorialkit-rb/types';
2
+ import { EditorConfig } from '../webcontainer/editor-config.js';
3
+ export interface EditorDocument {
4
+ value: string | Uint8Array;
5
+ loading: boolean;
6
+ filePath: string;
7
+ type: FileDescriptor['type'];
8
+ scroll?: ScrollPosition;
9
+ }
10
+ export interface ScrollPosition {
11
+ top: number;
12
+ left: number;
13
+ }
14
+ export type EditorDocuments = Record<string, EditorDocument | undefined>;
15
+ export declare class EditorStore {
16
+ editorConfig: import("nanostores").WritableAtom<EditorConfig>;
17
+ selectedFile: import("nanostores").WritableAtom<string | undefined>;
18
+ documents: import("nanostores").MapStore<EditorDocuments>;
19
+ files: import("nanostores").ReadableAtom<FileDescriptor[]>;
20
+ currentDocument: import("nanostores").ReadableAtom<EditorDocument | undefined>;
21
+ setEditorConfig(config?: EditorSchema): void;
22
+ setSelectedFile(filePath: string | undefined): void;
23
+ setDocuments(files: FilesRefList | Files): void;
24
+ updateScrollPosition(filePath: string, position: ScrollPosition): void;
25
+ addFileOrFolder(file: FileDescriptor): void;
26
+ updateFile(filePath: string, content: string | Uint8Array): boolean;
27
+ deleteFile(filePath: string): boolean;
28
+ onDocumentChanged(filePath: string, callback: (document: Readonly<EditorDocument>) => void): () => void;
29
+ }
@@ -0,0 +1,127 @@
1
+ import { atom, map, computed } from 'nanostores';
2
+ import { EditorConfig } from '../webcontainer/editor-config.js';
3
+ export class EditorStore {
4
+ editorConfig = atom(new EditorConfig());
5
+ selectedFile = atom();
6
+ documents = map({});
7
+ files = computed(this.documents, (documents) => Object.entries(documents).map(([path, doc]) => ({ path, type: doc?.type || 'file' })));
8
+ currentDocument = computed([this.documents, this.selectedFile], (documents, selectedFile) => {
9
+ if (!selectedFile) {
10
+ return undefined;
11
+ }
12
+ return documents[selectedFile];
13
+ });
14
+ setEditorConfig(config) {
15
+ this.editorConfig.set(new EditorConfig(config));
16
+ }
17
+ setSelectedFile(filePath) {
18
+ this.selectedFile.set(filePath);
19
+ }
20
+ setDocuments(files) {
21
+ // lesson, solution and template file entries are always files - empty folders are not supported
22
+ const type = 'file';
23
+ // check if it is a FilesRef
24
+ if (Array.isArray(files)) {
25
+ this.documents.set(Object.fromEntries(files[1].map((filePath) => {
26
+ return [
27
+ filePath,
28
+ {
29
+ value: '',
30
+ type,
31
+ loading: true,
32
+ filePath,
33
+ },
34
+ ];
35
+ })));
36
+ }
37
+ else {
38
+ const previousDocuments = this.documents.value;
39
+ this.documents.set(Object.fromEntries(Object.entries(files).map(([filePath, value]) => {
40
+ return [
41
+ filePath,
42
+ {
43
+ value,
44
+ type,
45
+ loading: false,
46
+ filePath,
47
+ scroll: previousDocuments?.[filePath]?.scroll,
48
+ },
49
+ ];
50
+ })));
51
+ }
52
+ }
53
+ updateScrollPosition(filePath, position) {
54
+ const documentState = this.documents.get()[filePath];
55
+ if (!documentState) {
56
+ return;
57
+ }
58
+ this.documents.setKey(filePath, {
59
+ ...documentState,
60
+ scroll: position,
61
+ });
62
+ }
63
+ addFileOrFolder(file) {
64
+ // when adding file or folder to empty folder, remove the empty folder from documents
65
+ const emptyFolder = this.files.get().find((f) => file.path.startsWith(f.path));
66
+ if (emptyFolder) {
67
+ this.documents.setKey(emptyFolder.path, undefined);
68
+ }
69
+ this.documents.setKey(file.path, {
70
+ filePath: file.path,
71
+ type: file.type,
72
+ value: '',
73
+ loading: false,
74
+ });
75
+ }
76
+ updateFile(filePath, content) {
77
+ const documentState = this.documents.get()[filePath];
78
+ if (!documentState) {
79
+ return false;
80
+ }
81
+ const currentContent = documentState.value;
82
+ const contentChanged = currentContent !== content;
83
+ if (contentChanged) {
84
+ this.documents.setKey(filePath, {
85
+ ...documentState,
86
+ value: content,
87
+ });
88
+ }
89
+ return contentChanged;
90
+ }
91
+ deleteFile(filePath) {
92
+ const documentState = this.documents.get()[filePath];
93
+ if (!documentState) {
94
+ return false;
95
+ }
96
+ this.documents.setKey(filePath, undefined);
97
+ return true;
98
+ }
99
+ onDocumentChanged(filePath, callback) {
100
+ const unsubscribeFromCurrentDocument = this.currentDocument.subscribe((document) => {
101
+ if (document?.filePath === filePath) {
102
+ callback(document);
103
+ }
104
+ });
105
+ const unsubscribeFromDocuments = this.documents.subscribe((documents) => {
106
+ const document = documents[filePath];
107
+ /**
108
+ * We grab the document from the store, but only call the callback if it is not loading anymore which means
109
+ * the content is loaded.
110
+ */
111
+ if (document && !document.loading) {
112
+ /**
113
+ * Call this in a `queueMicrotask` because the subscribe callback is called synchronoulsy,
114
+ * which causes the `unsubscribeFromDocuments` to not exist yet.
115
+ */
116
+ queueMicrotask(() => {
117
+ callback(document);
118
+ unsubscribeFromDocuments();
119
+ });
120
+ }
121
+ });
122
+ return () => {
123
+ unsubscribeFromDocuments();
124
+ unsubscribeFromCurrentDocument();
125
+ };
126
+ }
127
+ }
@@ -0,0 +1,113 @@
1
+ import type { FileDescriptor, Lesson } from '@tutorialkit-rb/types';
2
+ import type { WebContainer } from '@webcontainer/api';
3
+ import { type ReadableAtom } from 'nanostores';
4
+ import type { ITerminal } from '../utils/terminal.js';
5
+ import type { EditorConfig } from '../webcontainer/editor-config.js';
6
+ import { type BootStatus } from '../webcontainer/on-demand-boot.js';
7
+ import type { PreviewInfo } from '../webcontainer/preview-info.js';
8
+ import type { TerminalConfig } from '../webcontainer/terminal-config.js';
9
+ import { type EditorDocument, type EditorDocuments, type ScrollPosition } from './editor.js';
10
+ interface StoreOptions {
11
+ webcontainer: Promise<WebContainer>;
12
+ /**
13
+ * Whether or not authentication is used for the WebContainer API.
14
+ */
15
+ useAuth: boolean;
16
+ /**
17
+ * The base path to use when fetching files.
18
+ */
19
+ basePathname?: string;
20
+ }
21
+ export declare class TutorialStore {
22
+ private _webcontainer;
23
+ private _runner;
24
+ private _previewsStore;
25
+ private _editorStore;
26
+ private _terminalStore;
27
+ private _stepController;
28
+ private _lessonFilesFetcher;
29
+ private _lessonTask;
30
+ private _lesson;
31
+ private _ref;
32
+ private _themeRef;
33
+ private _lessonFiles;
34
+ private _lessonSolution;
35
+ private _lessonTemplate;
36
+ /**
37
+ * Whether or not the current lesson is fully loaded in WebContainer
38
+ * and in every stores.
39
+ */
40
+ readonly lessonFullyLoaded: import("nanostores").WritableAtom<boolean>;
41
+ constructor({ useAuth, webcontainer, basePathname }: StoreOptions);
42
+ /** @internal */
43
+ setLesson(lesson: Lesson, options?: {
44
+ ssr?: boolean;
45
+ }): void;
46
+ /** Instances of the preview tabs. */
47
+ get previews(): ReadableAtom<PreviewInfo[]>;
48
+ /** Configuration and instances of the terminal */
49
+ get terminalConfig(): ReadableAtom<TerminalConfig>;
50
+ /** Configuration of the editor and file tree */
51
+ get editorConfig(): ReadableAtom<EditorConfig>;
52
+ /** File that's currently open in the editor */
53
+ get currentDocument(): ReadableAtom<EditorDocument | undefined>;
54
+ /** Status of the webcontainer's booting */
55
+ get bootStatus(): ReadableAtom<BootStatus>;
56
+ /** Files that are available in the editor. */
57
+ get documents(): ReadableAtom<EditorDocuments>;
58
+ /** Paths of the files that are available in the lesson */
59
+ get files(): ReadableAtom<FileDescriptor[]>;
60
+ /** File that's currently selected in the file tree */
61
+ get selectedFile(): ReadableAtom<string | undefined>;
62
+ /** Currently active lesson */
63
+ get lesson(): Readonly<Lesson> | undefined;
64
+ /** @internal */
65
+ get ref(): ReadableAtom<unknown>;
66
+ /** @internal */
67
+ get themeRef(): ReadableAtom<unknown>;
68
+ /**
69
+ * Steps that the runner is or will be executing.
70
+ *
71
+ * @internal
72
+ */
73
+ get steps(): import("nanostores").WritableAtom<import("../webcontainer/steps.js").Steps | undefined>;
74
+ /** Check if file tree is visible */
75
+ hasFileTree(): boolean;
76
+ /** Check if editor is visible */
77
+ hasEditor(): boolean;
78
+ /** Check if lesson has any previews set */
79
+ hasPreviews(): boolean;
80
+ /** Check if lesson has any terminals set */
81
+ hasTerminalPanel(): boolean;
82
+ /** Check if lesson has solution files set */
83
+ hasSolution(): boolean;
84
+ /** Unlock webcontainer's boot process if it was in `'blocked'` state */
85
+ unblockBoot(): void;
86
+ /** Reset changed files back to lesson's initial state */
87
+ reset(): void;
88
+ /** Apply lesson solution into the lesson files */
89
+ solve(): void;
90
+ /** Set file from file tree as selected */
91
+ setSelectedFile(filePath: string | undefined): void;
92
+ /** Add new file to file tree */
93
+ addFile(filePath: string): Promise<void>;
94
+ /** Add new folder to file tree */
95
+ addFolder(folderPath: string): Promise<void>;
96
+ /** Update contents of file */
97
+ updateFile(filePath: string, content: string): void;
98
+ /** Update content of the active file */
99
+ setCurrentDocumentContent(newContent: string): void;
100
+ /** Update scroll position of the file in editor */
101
+ setCurrentDocumentScrollPosition(position: ScrollPosition): void;
102
+ /** @internal */
103
+ attachTerminal(id: string, terminal: ITerminal): void;
104
+ /** Callback that should be called when terminal resizes */
105
+ onTerminalResize(cols: number, rows: number): void;
106
+ /** Listen for file changes made in the editor */
107
+ onDocumentChanged(filePath: string, callback: (document: Readonly<EditorDocument>) => void): () => void;
108
+ /** Take snapshot of the current state of the lesson */
109
+ takeSnapshot(): {
110
+ files: Record<string, string>;
111
+ };
112
+ }
113
+ export {};