diff --git a/docs/tutorialkit.dev/astro.config.ts b/docs/tutorialkit.dev/astro.config.ts
index 45ca05c12..8e1836625 100644
--- a/docs/tutorialkit.dev/astro.config.ts
+++ b/docs/tutorialkit.dev/astro.config.ts
@@ -67,6 +67,10 @@ export default defineConfig({
label: 'Overriding Components',
link: '/guides/overriding-components/',
},
+ {
+ label: 'How to use TutorialKit API',
+ link: '/guides/how-to-use-tutorialkit-api/',
+ },
],
},
{
@@ -84,6 +88,10 @@ export default defineConfig({
label: 'React Components',
link: '/reference/react-components',
},
+ {
+ label: 'TutorialKit API',
+ link: '/reference/tutorialkit-api',
+ },
],
},
],
diff --git a/docs/tutorialkit.dev/src/content/docs/guides/how-to-use-tutorialkit-api.mdx b/docs/tutorialkit.dev/src/content/docs/guides/how-to-use-tutorialkit-api.mdx
new file mode 100644
index 000000000..881153651
--- /dev/null
+++ b/docs/tutorialkit.dev/src/content/docs/guides/how-to-use-tutorialkit-api.mdx
@@ -0,0 +1,260 @@
+---
+title: How to use TutorialKit API
+description: "Examples showing how to utilize TutorialKit APIs"
+---
+
+import { Tabs, TabItem } from '@astrojs/starlight/components';
+
+TutorialKit's API can be used in custom components to provide highly custom experience in the tutorials.
+Below are listed few examples to solve real-world use cases. See [TutorialKit API](/reference/tutorialkit-api/) for API documentation.
+
+## Access tutorial state
+
+In this example we'll read contents of `index.js` using Tutorial Store APIs.
+
+When user clicks `Help` button, we check the contents of `index.js` and provide them hints about next steps.
+
+
+
+
+
+
+
+
+```tsx title="src/components/HelpButton.tsx"
+import tutorialStore from "tutorialkit:store";
+import { Dialog } from "@tutorialkit/react";
+import { useState } from "react";
+import { parseModule } from "magicast";
+
+export default function HelpButton() {
+ const [message, setMessage] = useState(null);
+
+ function onClick() {
+ const files = tutorialStore.documents.value;
+ const index = files["/index.js"].value as string;
+
+ const message = verifyIndexJs(index);
+ setMessage(message);
+ }
+
+ return (
+ <>
+
+
+ {message && (
+
+ )}
+ >
+ );
+}
+
+function verifyIndexJs(code: string) {
+ const mod = parseModule(code);
+
+ const hasSumFunction =
+ mod.$ast.type === "Program" &&
+ mod.$ast.body.some(
+ (node) => node.type === "FunctionDeclaration" && node.id.name === "sum"
+ );
+
+ if (!hasSumFunction) {
+ return "Your index.js should have a sum function";
+ }
+
+ if (mod.exports.default?.$name !== "sum") {
+ return "Your index.js should export sum as default export";
+ }
+
+ return "All good";
+}
+```
+
+
+
+
+```mdx title="src/content/tutorial/chapter/part/lesson/content.mdx"
+---
+type: lesson
+title: TutorialKit API usage example
+focus: /index.js
+---
+
+import HelpButton from '@components/HelpButton';
+
+# TutorialKit API usage example
+
+Click this button if you get stuck:
+
+
+
+```
+
+
+
+
+
+## Write to terminal
+
+In this example we'll write commands to the terminal using Tutorial Store APIs.
+
+When user clicks `Run tests` button, we run `npm run test` command into the terminal.
+This command starts our `test` command defined in template's `package.json`.
+
+
+
+
+
+
+
+
+```tsx title="src/components/TerminalWriter.tsx"
+import tutorialStore from 'tutorialkit:store';
+
+export default function TerminalWriter() {
+ async function onClick() {
+ const terminal = tutorialStore.terminalConfig.value!.panels[0];
+ terminal.input('npm run test\n');
+ }
+
+ return (
+
+ );
+}
+```
+
+
+
+
+
+```mdx title="src/content/tutorial/chapter/part/lesson/content.mdx"
+---
+type: lesson
+title: Write to Terminal example
+---
+
+import TerminalWriter from "@components/TerminalWriter";
+
+# Write to Terminal example
+
+Fix counter.js
and run the tests!
+
+
+```
+
+
+
+
+## Provide feedback to user when lesson is solved
+
+In this example we'll use the Tutorial Core APIs to congratulate user when they solve the lesson code.
+
+Every time user edits the `math.js`, we run `node check-lesson.js` in the webcontainer and see if the process exits with non-erroneous exit code.
+Once the exit code indicates success, we inform user with message.
+
+
+
+
+
+
+
+
+```tsx title="src/components/LessonChecker.tsx"
+import { Dialog } from '@tutorialkit/react';
+import { useEffect, useState } from 'react';
+import { webcontainer } from 'tutorialkit:core';
+import tutorialStore from 'tutorialkit:store';
+
+export default function LessonChecker() {
+ const [success, setSuccess] = useState(false);
+
+ useEffect(() => {
+ let timeout: ReturnType | undefined = undefined;
+
+ const unsubscribe = tutorialStore.onDocumentChanged('/math.js', () => {
+ clearTimeout(timeout);
+
+ timeout = setTimeout(async () => {
+ if (await checkLesson()) {
+ setSuccess(true);
+ unsubscribe();
+ }
+ }, 250);
+ });
+
+ return function cleanup() {
+ unsubscribe();
+ clearTimeout(timeout);
+ };
+ }, []);
+
+ return (
+ <>
+ {success && (
+
+ )}
+ >
+ );
+}
+
+async function checkLesson(): Promise {
+ const webcontainerInstance = await webcontainer;
+ const process = await webcontainerInstance.spawn('node', ['./check-lesson.mjs']);
+
+ const exitCode = await process.exit;
+
+ return exitCode === 0;
+}
+```
+
+
+
+
+
+```js title="src/templates/default/check-lesson.mjs"
+import * as math from './math.js';
+
+if (math.sum(25, 32) !== 57) {
+ process.exit(1);
+}
+
+if (math.multiply(3, 25) !== 75) {
+ process.exit(1);
+}
+
+process.exit(0);
+```
+
+
+
+
+
+```mdx title="src/content/tutorial/chapter/part/lesson/content.mdx"
+---
+type: lesson
+title: TutorialKit API usage example
+focus: /math.js
+---
+
+import LessonChecker from '@components/LessonChecker';
+
+# TutorialKit API usage example
+
+Solve math.js
and you'll see notification about completed lesson!
+
+
+```
+
+
+
diff --git a/docs/tutorialkit.dev/src/content/docs/reference/tutorialkit-api.mdx b/docs/tutorialkit.dev/src/content/docs/reference/tutorialkit-api.mdx
new file mode 100644
index 000000000..f82a88e6e
--- /dev/null
+++ b/docs/tutorialkit.dev/src/content/docs/reference/tutorialkit-api.mdx
@@ -0,0 +1,363 @@
+---
+title: TutorialKit API
+description: Use TutorialKit's lower level APIs
+---
+
+TutorialKit exposes low level APIs that authors can utilize to provide highly custom experience in their tutorials.
+These APIs allow authors to control internal parts of TutorialKit. See [How to use TutorialKit API](/guides/how-to-use-tutorialkit-api/) guide for examples.
+
+## Tutorial Store
+
+You can access Tutorial Store by importing the `tutorialkit:store` entrypoint.
+
+```ts
+import tutorialStore from "tutorialkit:store";
+```
+
+:::note
+Using `tutorialkit:store` is **experimental** at the moment.
+This module may introduce breaking changes in patch and minor version updates. Pay extra attention when updating the versions.
+
+You can help us stabilize the API by providing feedback at [Stabilizing `tutorialkit:store` API | Github Discussions](https://github.com/stackblitz/tutorialkit/discussions/351).
+Please let us know how you are using this API.
+:::
+
+### Common types
+
+- `ReadableAtom` from [`nanostores`](https://www.npmjs.com/package/nanostores)
+- `WebContainerProcess` from [`@webcontainer/api`](https://www.npmjs.com/package/@webcontainer/api)
+
+### Properties
+
+#### `previews`
+
+Type: `ReadableAtom`
+
+```ts
+import type { PreviewSchema } from '@tutorialkit/types';
+
+class PreviewInfo {
+ readonly portInfo: PortInfo;
+ title?: string;
+ pathname?: string;
+ get url(): string | undefined;
+ get port(): number;
+ get baseUrl(): string | undefined;
+ get ready(): boolean;
+ static parse(preview: Exclude[0]): Preview;
+ static equals(a: PreviewInfo, b: PreviewInfo): boolean;
+}
+class PortInfo {
+ readonly port: number;
+ origin?: string | undefined;
+ ready: boolean;
+}
+interface Preview {
+ port: number;
+ pathname?: string;
+ title?: string;
+}
+```
+
+Instances of the preview tabs.
+
+#### `terminalConfig`
+
+Type: `ReadableAtom`
+
+```ts
+import type { TerminalPanelType, TerminalSchema } from '@tutorialkit/types';
+import type { WebContainerProcess } from '@webcontainer/api';
+
+class TerminalConfig {
+ get panels(): TerminalPanel[];
+ get activePanel(): number;
+ get defaultOpen(): boolean;
+}
+
+class TerminalPanel implements ITerminal {
+ readonly type: TerminalPanelType;
+ static panelCount: Record;
+ static resetCount(): void;
+ readonly id: string;
+ readonly title: string;
+ get terminal(): ITerminal | undefined;
+ get process(): WebContainerProcess | undefined;
+ get processOptions(): {
+ allowRedirects: boolean;
+ allowCommands: string[] | undefined;
+ } | undefined;
+ get cols(): number | undefined;
+ get rows(): number | undefined;
+ reset(): void;
+ write(data: string): void;
+ onData(callback: (data: string) => void): void;
+ attachProcess(process: WebContainerProcess): void;
+ attachTerminal(terminal: ITerminal): void;
+}
+
+interface ITerminal {
+ readonly cols?: number;
+ readonly rows?: number;
+ reset: () => void;
+ write: (data: string) => void;
+ onData: (cb: (data: string) => void) => void;
+}
+```
+
+Configuration and instances of the terminal.
+
+#### `editorConfig`
+
+Type: `ReadableAtom`
+
+```ts
+class EditorConfig {
+ get visible(): boolean;
+ get fileTree(): {
+ visible: boolean;
+ allowEdits: false | string[];
+ };
+}
+```
+
+Configuration of the editor and file tree.
+
+#### `currentDocument`
+
+Type: `ReadableAtom`
+
+```ts
+import type { FileDescriptor } from '@tutorialkit/types';
+
+interface EditorDocument {
+ value: string | Uint8Array;
+ loading: boolean;
+ filePath: string;
+ type: FileDescriptor['type'];
+ scroll?: ScrollPosition;
+}
+
+interface ScrollPosition {
+ top: number;
+ left: number;
+}
+```
+
+File that's currently open in the editor.
+
+#### `bootStatus`
+
+Type: `ReadableAtom`
+
+```ts
+type BootStatus = 'unknown' | 'blocked' | 'booting' | 'booted';
+```
+
+Status of the webcontainer's booting.
+
+#### `documents`
+
+Type: `ReadableAtom`
+
+```ts
+import type { FileDescriptor } from '@tutorialkit/types';
+
+type EditorDocuments = Record
+
+interface EditorDocument {
+ value: string | Uint8Array;
+ loading: boolean;
+ filePath: string;
+ type: FileDescriptor['type'];
+ scroll?: ScrollPosition;
+}
+
+interface ScrollPosition {
+ top: number;
+ left: number;
+}
+```
+
+Files that are available in the editor.
+
+#### `files`
+
+Type: `ReadableAtom`
+
+```ts
+type FileDescriptor = {
+ path: string;
+ type: 'file' | 'folder';
+}
+```
+
+Paths of the files that are available in the lesson.
+
+#### `selectedFile`
+
+Type: `ReadableAtom`
+
+File that's currently selected in the file tree.
+
+#### `lesson`
+
+Type: `Readonly | undefined`
+
+```ts
+import type { Lesson } from '@tutorialkit/types';
+```
+
+Currently active lesson.
+
+### Methods
+
+#### `hasFileTree`
+
+Type: `() => boolean`
+
+Check if file tree is visible.
+
+#### `hasEditor`
+
+Type: `() => boolean`
+
+Check if editor is visible.
+
+#### `hasPreviews`
+
+Type: `() => boolean`
+
+Check if lesson has any previews set.
+
+#### `hasTerminalPanel`
+
+Type: `() => boolean`
+
+Check if lesson has any terminals set.
+
+#### `hasSolution`
+
+Type: `() => boolean`
+
+Check if lesson has solution files set.
+
+#### `unblockBoot`
+
+Type: `() => void`
+
+Unlock webcontainer's boot process if it was in `'blocked'` state.
+
+#### `reset`
+
+Type: `() => void`
+
+Reset changed files back to lesson's initial state.
+
+#### `solve`
+
+Type: `() => void`
+
+Apply lesson solution into the lesson files.
+
+#### `setSelectedFile`
+
+Type: `(filePath: string | undefined) => void`
+
+Set file from file tree as selected.
+
+#### `addFile`
+
+Type: `(filePath: string) => Promise`
+
+Add new file to file tree.
+Throws error with message `FILE_EXISTS` if file exists already on file system.
+
+#### `addFolder`
+
+Type: `(folderPath: string) => Promise`
+
+Add new file to file tree.
+Throws error with message `FOLDER_EXISTS` if folder exists already on file system.
+
+#### `updateFile`
+
+Type: `(filePath: string, content: string) => void`
+
+Update contents of file.
+
+#### `setCurrentDocumentContent`
+
+Type: `(newContent: string) => void`
+
+Update content of the active file.
+
+#### `setCurrentDocumentScrollPosition`
+
+Type: `(position: ScrollPosition) => void`
+
+```ts
+interface ScrollPosition {
+ top: number;
+ left: number;
+}
+```
+
+Update scroll position of the file in editor.
+
+#### `onTerminalResize`
+
+Type: `(cols: number, rows: number) => void`
+
+Callback that should be called when terminal resizes.
+
+#### `onDocumentChanged`
+
+Type: `(filePath: string, callback: (document: Readonly) => void) => () => void`
+
+```ts
+import type { FileDescriptor } from '@tutorialkit/types';
+
+interface EditorDocument {
+ value: string | Uint8Array;
+ loading: boolean;
+ filePath: string;
+ type: FileDescriptor['type'];
+ scroll?: ScrollPosition;
+}
+
+interface ScrollPosition {
+ top: number;
+ left: number;
+}
+```
+
+Listen for file changes made in the editor.
+
+#### `takeSnapshot`
+
+Type: `() => { files: Record }`
+
+Take snapshot of the current state of the lesson.
+
+## Tutorial Core
+
+You can access Tutorial Core by importing the `tutorialkit:core` entrypoint.
+
+```ts
+import * as core from "tutorialkit:core";
+```
+
+The Tutorial Core provides access to webcontainer APIs. You can use it for example to read and modify files on the virtual file system.
+
+### Common Types
+
+- `WebContainer` from [`@webcontainer/api`](https://www.npmjs.com/package/@webcontainer/api)
+
+### Properties
+
+#### `webcontainer`
+
+Type: `Promise`
+
+Promise that resolves to the webcontainer that's running in the current lesson.
diff --git a/packages/astro/types.d.ts b/packages/astro/types.d.ts
index 7079637a5..a20bf5974 100644
--- a/packages/astro/types.d.ts
+++ b/packages/astro/types.d.ts
@@ -1,3 +1,5 @@
+/* eslint-disable @blitz/lines-around-comment */
+
declare module 'tutorialkit:store' {
const tutorialStore: import('@tutorialkit/runtime').TutorialStore;
@@ -5,5 +7,6 @@ declare module 'tutorialkit:store' {
}
declare module 'tutorialkit:core' {
+ /** Promise that resolves to the webcontainer that's running in the current lesson. */
export const webcontainer: Promise;
}
diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts
index 5c500ac98..74ea701e2 100644
--- a/packages/runtime/src/store/index.ts
+++ b/packages/runtime/src/store/index.ts
@@ -129,6 +129,7 @@ export class TutorialStore {
}
}
+ /** @internal */
setLesson(lesson: Lesson, options: { ssr?: boolean } = {}) {
if (lesson === this._lesson) {
return;
@@ -191,61 +192,71 @@ export class TutorialStore {
);
}
+ /** Instances of the preview tabs. */
get previews(): ReadableAtom {
return this._previewsStore.previews;
}
+ /** Configuration and instances of the terminal */
get terminalConfig(): ReadableAtom {
return this._terminalStore.terminalConfig;
}
+ /** Configuration of the editor and file tree */
get editorConfig(): ReadableAtom {
return this._editorStore.editorConfig;
}
+ /** File that's currently open in the editor */
get currentDocument(): ReadableAtom {
return this._editorStore.currentDocument;
}
+ /** Status of the webcontainer's booting */
get bootStatus(): ReadableAtom {
return bootStatus;
}
+ /** Files that are available in the editor. */
get documents(): ReadableAtom {
return this._editorStore.documents;
}
+ /** Paths of the files that are available in the lesson */
get files(): ReadableAtom {
return this._editorStore.files;
}
- get template(): Files | undefined {
- return this._lessonTemplate;
- }
-
+ /** File that's currently selected in the file tree */
get selectedFile(): ReadableAtom {
return this._editorStore.selectedFile;
}
+ /** Currently active lesson */
get lesson(): Readonly | undefined {
return this._lesson;
}
+ /** @internal */
get ref(): ReadableAtom {
return this._ref;
}
+ /** @internal */
get themeRef(): ReadableAtom {
return this._themeRef;
}
/**
* Steps that the runner is or will be executing.
+ *
+ * @internal
*/
get steps() {
return this._stepController.steps;
}
+ /** Check if file tree is visible */
hasFileTree(): boolean {
if (!this._lesson) {
return false;
@@ -254,6 +265,7 @@ export class TutorialStore {
return this.editorConfig.get().fileTree.visible;
}
+ /** Check if editor is visible */
hasEditor(): boolean {
if (!this._lesson) {
return false;
@@ -262,6 +274,7 @@ export class TutorialStore {
return this.editorConfig.get().visible;
}
+ /** Check if lesson has any previews set */
hasPreviews(): boolean {
if (!this._lesson) {
return false;
@@ -272,18 +285,22 @@ export class TutorialStore {
return previews !== false;
}
+ /** Check if lesson has any terminals set */
hasTerminalPanel(): boolean {
return this._terminalStore.hasTerminalPanel();
}
+ /** Check if lesson has solution files set */
hasSolution(): boolean {
return !!this._lesson && Object.keys(this._lesson.solution[1]).length >= 1;
}
+ /** Unlock webcontainer's boot process if it was in `'blocked'` state */
unblockBoot() {
unblockBoot();
}
+ /** Reset changed files back to lesson's initial state */
reset() {
const isReady = this.lessonFullyLoaded.value;
@@ -295,6 +312,7 @@ export class TutorialStore {
this._runner.updateFiles(this._lessonFiles);
}
+ /** Apply lesson solution into the lesson files */
solve() {
const isReady = this.lessonFullyLoaded.value;
@@ -308,10 +326,12 @@ export class TutorialStore {
this._runner.updateFiles(files);
}
+ /** Set file from file tree as selected */
setSelectedFile(filePath: string | undefined) {
this._editorStore.setSelectedFile(filePath);
}
+ /** Add new file to file tree */
async addFile(filePath: string): Promise {
// always select the existing or newly created file
this.setSelectedFile(filePath);
@@ -329,6 +349,7 @@ export class TutorialStore {
this._runner.updateFile(filePath, '');
}
+ /** Add new folder to file tree */
async addFolder(folderPath: string) {
// prevent creating duplicates
if (this._editorStore.files.get().some((file) => file.path.startsWith(folderPath))) {
@@ -343,6 +364,7 @@ export class TutorialStore {
this._runner.createFolder(folderPath);
}
+ /** Update contents of file */
updateFile(filePath: string, content: string) {
const hasChanged = this._editorStore.updateFile(filePath, content);
@@ -351,10 +373,7 @@ export class TutorialStore {
}
}
- updateFiles(files: Files) {
- this._runner.updateFiles(files);
- }
-
+ /** Update content of the active file */
setCurrentDocumentContent(newContent: string) {
const filePath = this.currentDocument.get()?.filePath;
@@ -365,6 +384,7 @@ export class TutorialStore {
this.updateFile(filePath, newContent);
}
+ /** Update scroll position of the file in editor */
setCurrentDocumentScrollPosition(position: ScrollPosition) {
const editorDocument = this.currentDocument.get();
@@ -377,10 +397,12 @@ export class TutorialStore {
this._editorStore.updateScrollPosition(filePath, position);
}
+ /** @internal */
attachTerminal(id: string, terminal: ITerminal) {
this._terminalStore.attachTerminal(id, terminal);
}
+ /** Callback that should be called when terminal resizes */
onTerminalResize(cols: number, rows: number) {
if (cols && rows) {
this._terminalStore.onTerminalResize(cols, rows);
@@ -388,14 +410,12 @@ export class TutorialStore {
}
}
+ /** Listen for file changes made in the editor */
onDocumentChanged(filePath: string, callback: (document: Readonly) => void) {
return this._editorStore.onDocumentChanged(filePath, callback);
}
- refreshStyles() {
- this._themeRef.set(this._themeRef.get() + 1);
- }
-
+ /** Take snapshot of the current state of the lesson */
takeSnapshot() {
return this._runner.takeSnapshot();
}