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. + + + Open in StackBlitz + + + + + +```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 && ( + setMessage(null)}> + {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`. + + + Open in StackBlitz + + + + + +```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. + + + Open in StackBlitz + + + + + +```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 && ( + setSuccess(false)}> + Lesson complete, congratulations! 🎉 + + )} + + ); +} + +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(); }