Skip to content

Commit af428c8

Browse files
authored
feat: add 'Open in StackBlitz'-button (#219)
1 parent 2986157 commit af428c8

File tree

16 files changed

+171
-4
lines changed

16 files changed

+171
-4
lines changed

docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,17 @@ You can instruct Github to show the source code instead by adding `plain=1` quer
211211

212212
:::
213213

214+
### `openInStackBlitz`
215+
Display a link for opening current lesson in StackBlitz.
216+
<PropertyTable inherited type="OpenInStackBlitz" />
217+
218+
The `OpenInStackBlitz` type has the following shape:
219+
220+
```ts
221+
type OpenInStackBlitz =
222+
| boolean
223+
| { projectTitle?: string, projectDescription?: string, projectTemplate?: TemplateType }
224+
225+
type TemplateType = "html" | "node" | "angular-cli" | "create-react-app" | "javascript" | "polymer" | "typescript" | "vue"
226+
227+
```

packages/astro/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@expressive-code/plugin-collapsible-sections": "^0.35.3",
3636
"@expressive-code/plugin-line-numbers": "^0.35.3",
3737
"@nanostores/react": "0.7.2",
38+
"@stackblitz/sdk": "^1.11.0",
3839
"@tutorialkit/components-react": "workspace:*",
3940
"@tutorialkit/runtime": "workspace:*",
4041
"@tutorialkit/theme": "workspace:*",
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<button
2+
transition:persist
3+
title="Open in StackBlitz"
4+
data-id="open-in-stackblitz"
5+
class="flex items-center font-size-3.5 text-tk-elements-topBar-iconButton-iconColor hover:text-tk-elements-topBar-iconButton-iconColorHover transition-theme bg-tk-elements-topBar-iconButton-backgroundColor hover:bg-tk-elements-topBar-iconButton-backgroundColorHover p-1 rounded-md"
6+
>
7+
<svg viewBox="0 0 28 28" aria-hidden="true" height="24" width="24">
8+
<path fill="currentColor" d="M12.747 16.273h-7.46L18.925 1.5l-3.671 10.227h7.46L9.075 26.5l3.671-10.227z"></path>
9+
</svg>
10+
</button>
11+
12+
<script>
13+
import StackBlitzSDK from '@stackblitz/sdk';
14+
import { tutorialStore } from '../components/webcontainer.js';
15+
16+
// initialize handlers on each page load as it's possible some pages disable openInStackBlitz
17+
document.addEventListener('astro:page-load', onInit);
18+
19+
function onInit() {
20+
const buttons = document.querySelectorAll('[data-id="open-in-stackblitz"]');
21+
buttons.forEach((button) => button.addEventListener('click', onClick));
22+
}
23+
24+
function onClick() {
25+
const lesson = tutorialStore.lesson;
26+
27+
if (!lesson) {
28+
throw new Error('Missing lesson');
29+
}
30+
31+
const snapshot = tutorialStore.takeSnapshot();
32+
const options = typeof lesson.data.openInStackBlitz === 'object' ? lesson.data.openInStackBlitz : {};
33+
34+
StackBlitzSDK.openProject({
35+
title: options.projectTitle || 'Project generated by TutorialKit',
36+
description: options.projectDescription,
37+
template: options.projectTemplate || 'node',
38+
files: snapshot.files,
39+
});
40+
}
41+
</script>

packages/astro/src/default/components/TopBar.astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
<div class="flex flex-1">
55
<slot name="logo" />
66
</div>
7+
<div class="mr-2">
8+
<slot name="open-in-stackblitz-link" />
9+
</div>
710
<div>
811
<slot name="theme-switch" />
912
</div>

packages/astro/src/default/components/TopBarWrapper.astro

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
---
22
import { TopBar } from 'tutorialkit:override-components';
3+
import type { Lesson } from '@tutorialkit/types';
34
import { ThemeSwitch } from './ThemeSwitch';
45
import { LoginButton } from './LoginButton';
6+
import OpenInStackblitzLink from './OpenInStackblitzLink.astro';
57
import Logo from './Logo.astro';
68
import { useAuth } from './setup';
79
810
interface Props {
911
logoLink: string;
12+
openInStackBlitz: Lesson['data']['openInStackBlitz'];
1013
}
1114
12-
const { logoLink } = Astro.props;
15+
const { logoLink, openInStackBlitz } = Astro.props;
1316
---
1417

1518
<TopBar>
1619
<Logo slot="logo" logoLink={logoLink ?? '/'} />
1720

21+
{openInStackBlitz && <OpenInStackblitzLink slot="open-in-stackblitz-link" />}
22+
1823
<ThemeSwitch client:load transition:persist slot="theme-switch" />
1924

2025
{useAuth && <LoginButton client:load transition:persist slot="login-button" />}

packages/astro/src/default/pages/[...slug].astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const { lesson, logoLink, navList, title } = Astro.props as Props;
2121
<PageLoadingIndicator />
2222
<div id="previews-container"></div>
2323
<main class="max-w-full flex flex-col h-full overflow-hidden" data-swap-root>
24-
<TopBarWrapper logoLink={logoLink ?? '/'} />
24+
<TopBarWrapper logoLink={logoLink ?? '/'} openInStackBlitz={lesson.data.openInStackBlitz} />
2525
<MainContainer lesson={lesson} navList={navList} />
2626
</main>
2727
</Layout>

packages/astro/src/default/utils/content.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export async function getTutorial(): Promise<Tutorial> {
4141
// default template if not specified
4242
tutorialMetaData.template ??= 'default';
4343
tutorialMetaData.i18n = Object.assign({ ...DEFAULT_LOCALIZATION }, tutorialMetaData.i18n);
44+
tutorialMetaData.openInStackBlitz ??= true;
4445

4546
_tutorial.logoLink = data.logoLink;
4647
} else if (type === 'part') {
@@ -257,6 +258,7 @@ export async function getTutorial(): Promise<Tutorial> {
257258
'focus',
258259
'i18n',
259260
'editPageLink',
261+
'openInStackBlitz',
260262
],
261263
),
262264
};

packages/cli/src/commands/eject/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ interface PackageJson {
1818
}
1919

2020
const TUTORIALKIT_VERSION = pkg.version;
21-
const REQUIRED_DEPENDENCIES = ['@tutorialkit/runtime', '@webcontainer/api', 'nanostores', '@nanostores/react', 'kleur'];
21+
const REQUIRED_DEPENDENCIES = [
22+
'@tutorialkit/runtime',
23+
'@webcontainer/api',
24+
'nanostores',
25+
'@nanostores/react',
26+
'kleur',
27+
'@stackblitz/sdk',
28+
];
2229

2330
export function ejectRoutes(flags: Arguments) {
2431
if (flags._[1] === 'help' || flags.help || flags.h) {

packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ exports[`create and eject a project 1`] = `
157157
"src/components/MobileContentToggle.astro",
158158
"src/components/NavCard.astro",
159159
"src/components/NavWrapper.tsx",
160+
"src/components/OpenInStackblitzLink.astro",
160161
"src/components/PageLoadingIndicator.astro",
161162
"src/components/ResizablePanel.astro",
162163
"src/components/ThemeSwitch.tsx",

packages/runtime/src/store/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ export class TutorialStore {
207207
return this._editorStore.documents;
208208
}
209209

210+
get template(): Files | undefined {
211+
return this._lessonTemplate;
212+
}
213+
210214
get selectedFile(): ReadableAtom<string | undefined> {
211215
return this._editorStore.selectedFile;
212216
}
@@ -352,4 +356,8 @@ export class TutorialStore {
352356
refreshStyles() {
353357
this._themeRef.set(this._themeRef.get() + 1);
354358
}
359+
360+
takeSnapshot() {
361+
return this._runner.takeSnapshot();
362+
}
355363
}

packages/runtime/src/tutorial-runner.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export class TutorialRunner {
6565

6666
// this strongly assumes that there's a single package json which might not be true
6767
private _packageJsonContent = '';
68+
private _packageJsonPath = '';
6869

6970
constructor(
7071
private _webcontainer: Promise<WebContainer>,
@@ -188,7 +189,7 @@ export class TutorialRunner {
188189
this._currentTemplate = { ...template };
189190
this._currentFiles = { ...files };
190191

191-
this._updateDirtyState(files);
192+
this._updateDirtyState({ ...template, ...files });
192193
},
193194
{ ignoreCancel: true, signal },
194195
);
@@ -302,6 +303,53 @@ export class TutorialRunner {
302303
);
303304
}
304305

306+
/**
307+
* Get snapshot of runner's current files.
308+
* Also prepares `package.json`'s `stackblitz.startCommand` with runner's commands.
309+
*
310+
* Note that file paths do not contain the leading `/`.
311+
*/
312+
takeSnapshot() {
313+
const files: Record<string, string> = {};
314+
315+
// first add template files
316+
for (const [filePath, value] of Object.entries(this._currentTemplate || {})) {
317+
if (typeof value === 'string') {
318+
files[filePath.slice(1)] = value;
319+
}
320+
}
321+
322+
// next overwrite with files from editor
323+
for (const [filePath, value] of Object.entries(this._currentFiles || {})) {
324+
if (typeof value === 'string') {
325+
files[filePath.slice(1)] = value;
326+
}
327+
}
328+
329+
if (this._packageJsonContent) {
330+
let packageJson;
331+
332+
try {
333+
packageJson = JSON.parse(this._packageJsonContent);
334+
} catch {}
335+
336+
// add start commands when missing
337+
if (packageJson && !packageJson.stackblitz?.startCommand) {
338+
const mainCommand = this._currentRunCommands?.mainCommand?.shellCommand;
339+
const prepareCommands = (this._currentRunCommands?.prepareCommands || []).map((c) => c.shellCommand);
340+
const startCommand = [...prepareCommands, mainCommand].filter(Boolean).join(' && ');
341+
342+
files[this._packageJsonPath.slice(1)] = JSON.stringify(
343+
{ ...packageJson, stackblitz: { startCommand } },
344+
null,
345+
2,
346+
);
347+
}
348+
}
349+
350+
return { files };
351+
}
352+
305353
private async _runCommands(webcontainer: WebContainer, commands: Commands, signal: AbortSignal) {
306354
const output = this._terminalStore.getOutputPanel();
307355

@@ -417,6 +465,7 @@ export class TutorialRunner {
417465
for (const filePath in files) {
418466
if (filePath.endsWith('/package.json') && files[filePath] != this._packageJsonContent) {
419467
this._packageJsonContent = files[filePath] as string;
468+
this._packageJsonPath = filePath;
420469
this._packageJsonDirty = true;
421470

422471
return;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
---
22
type: chapter
33
title: The second chapter in part 1
4+
openInStackBlitz: true
45
---
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
---
22
type: chapter
33
title: The first chatper in part 2
4+
openInStackBlitz: false
45
---

packages/template/src/content/tutorial/meta.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@ prepareCommands:
99
- ['npm install', 'Installing dependencies']
1010
i18n:
1111
partTemplate: ${title}
12+
openInStackBlitz:
13+
projectTitle: Example Title
14+
projectDescription: Example Description
1215
---

packages/types/src/schemas/common.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,30 @@ export const webcontainerSchema = commandsSchema.extend({
212212
.describe(
213213
'Display a link in lesson for editing the page content. The value is a URL pattern where `${path}` is replaced with the lesson’s location relative to `src/content/tutorial`.',
214214
),
215+
openInStackBlitz: z
216+
.union([
217+
// `false` for disabling the link
218+
z.boolean(),
219+
220+
z.strictObject({
221+
projectTitle: z.string().optional(),
222+
projectDescription: z.string().optional(),
223+
projectTemplate: z
224+
.union([
225+
z.literal('html'),
226+
z.literal('node'),
227+
z.literal('angular-cli'),
228+
z.literal('create-react-app'),
229+
z.literal('javascript'),
230+
z.literal('polymer'),
231+
z.literal('typescript'),
232+
z.literal('vue'),
233+
])
234+
.optional(),
235+
}),
236+
])
237+
.optional()
238+
.describe('Display a link for opening current lesson in StackBlitz.'),
215239
});
216240

217241
export const baseSchema = webcontainerSchema.extend({

pnpm-lock.yaml

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)