Skip to content

feat: add 'Open in StackBlitz'-button #219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,17 @@ You can instruct Github to show the source code instead by adding `plain=1` quer

:::

### `openInStackBlitz`
Display a link for opening current lesson in StackBlitz.
<PropertyTable inherited type="OpenInStackBlitz" />

The `OpenInStackBlitz` type has the following shape:

```ts
type OpenInStackBlitz =
| boolean
| { projectTitle?: string, projectDescription?: string, projectTemplate?: TemplateType }

type TemplateType = "html" | "node" | "angular-cli" | "create-react-app" | "javascript" | "polymer" | "typescript" | "vue"

```
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@expressive-code/plugin-collapsible-sections": "^0.35.3",
"@expressive-code/plugin-line-numbers": "^0.35.3",
"@nanostores/react": "0.7.2",
"@stackblitz/sdk": "^1.11.0",
"@tutorialkit/components-react": "workspace:*",
"@tutorialkit/runtime": "workspace:*",
"@tutorialkit/theme": "workspace:*",
Expand Down
41 changes: 41 additions & 0 deletions packages/astro/src/default/components/OpenInStackblitzLink.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<button
transition:persist
title="Open in StackBlitz"
data-id="open-in-stackblitz"
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"
>
<svg viewBox="0 0 28 28" aria-hidden="true" height="24" width="24">
<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>
</svg>
</button>

<script>
import StackBlitzSDK from '@stackblitz/sdk';
import { tutorialStore } from '../components/webcontainer.js';

// initialize handlers on each page load as it's possible some pages disable openInStackBlitz
document.addEventListener('astro:page-load', onInit);

function onInit() {
const buttons = document.querySelectorAll('[data-id="open-in-stackblitz"]');
buttons.forEach((button) => button.addEventListener('click', onClick));
}

function onClick() {
const lesson = tutorialStore.lesson;

if (!lesson) {
throw new Error('Missing lesson');
}

const snapshot = tutorialStore.takeSnapshot();
const options = typeof lesson.data.openInStackBlitz === 'object' ? lesson.data.openInStackBlitz : {};

StackBlitzSDK.openProject({
title: options.projectTitle || 'Project generated by TutorialKit',
description: options.projectDescription,
template: options.projectTemplate || 'node',
files: snapshot.files,
});
}
</script>
3 changes: 3 additions & 0 deletions packages/astro/src/default/components/TopBar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<div class="flex flex-1">
<slot name="logo" />
</div>
<div class="mr-2">
<slot name="open-in-stackblitz-link" />
</div>
<div>
<slot name="theme-switch" />
</div>
Expand Down
7 changes: 6 additions & 1 deletion packages/astro/src/default/components/TopBarWrapper.astro
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
---
import { TopBar } from 'tutorialkit:override-components';
import type { Lesson } from '@tutorialkit/types';
import { ThemeSwitch } from './ThemeSwitch';
import { LoginButton } from './LoginButton';
import OpenInStackblitzLink from './OpenInStackblitzLink.astro';
import Logo from './Logo.astro';
import { useAuth } from './setup';

interface Props {
logoLink: string;
openInStackBlitz: Lesson['data']['openInStackBlitz'];
}

const { logoLink } = Astro.props;
const { logoLink, openInStackBlitz } = Astro.props;
---

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

{openInStackBlitz && <OpenInStackblitzLink slot="open-in-stackblitz-link" />}

<ThemeSwitch client:load transition:persist slot="theme-switch" />

{useAuth && <LoginButton client:load transition:persist slot="login-button" />}
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/default/pages/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const { lesson, logoLink, navList, title } = Astro.props as Props;
<PageLoadingIndicator />
<div id="previews-container"></div>
<main class="max-w-full flex flex-col h-full overflow-hidden" data-swap-root>
<TopBarWrapper logoLink={logoLink ?? '/'} />
<TopBarWrapper logoLink={logoLink ?? '/'} openInStackBlitz={lesson.data.openInStackBlitz} />
<MainContainer lesson={lesson} navList={navList} />
</main>
</Layout>
2 changes: 2 additions & 0 deletions packages/astro/src/default/utils/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function getTutorial(): Promise<Tutorial> {
// default template if not specified
tutorialMetaData.template ??= 'default';
tutorialMetaData.i18n = Object.assign({ ...DEFAULT_LOCALIZATION }, tutorialMetaData.i18n);
tutorialMetaData.openInStackBlitz ??= true;

_tutorial.logoLink = data.logoLink;
} else if (type === 'part') {
Expand Down Expand Up @@ -257,6 +258,7 @@ export async function getTutorial(): Promise<Tutorial> {
'focus',
'i18n',
'editPageLink',
'openInStackBlitz',
],
),
};
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/eject/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ interface PackageJson {
}

const TUTORIALKIT_VERSION = pkg.version;
const REQUIRED_DEPENDENCIES = ['@tutorialkit/runtime', '@webcontainer/api', 'nanostores', '@nanostores/react', 'kleur'];
const REQUIRED_DEPENDENCIES = [
'@tutorialkit/runtime',
'@webcontainer/api',
'nanostores',
'@nanostores/react',
'kleur',
'@stackblitz/sdk',
];

export function ejectRoutes(flags: Arguments) {
if (flags._[1] === 'help' || flags.help || flags.h) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ exports[`create and eject a project 1`] = `
"src/components/MobileContentToggle.astro",
"src/components/NavCard.astro",
"src/components/NavWrapper.tsx",
"src/components/OpenInStackblitzLink.astro",
"src/components/PageLoadingIndicator.astro",
"src/components/ResizablePanel.astro",
"src/components/ThemeSwitch.tsx",
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ export class TutorialStore {
return this._editorStore.documents;
}

get template(): Files | undefined {
return this._lessonTemplate;
}

get selectedFile(): ReadableAtom<string | undefined> {
return this._editorStore.selectedFile;
}
Expand Down Expand Up @@ -352,4 +356,8 @@ export class TutorialStore {
refreshStyles() {
this._themeRef.set(this._themeRef.get() + 1);
}

takeSnapshot() {
return this._runner.takeSnapshot();
}
}
51 changes: 50 additions & 1 deletion packages/runtime/src/tutorial-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export class TutorialRunner {

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

constructor(
private _webcontainer: Promise<WebContainer>,
Expand Down Expand Up @@ -188,7 +189,7 @@ export class TutorialRunner {
this._currentTemplate = { ...template };
this._currentFiles = { ...files };

this._updateDirtyState(files);
this._updateDirtyState({ ...template, ...files });
},
{ ignoreCancel: true, signal },
);
Expand Down Expand Up @@ -302,6 +303,53 @@ export class TutorialRunner {
);
}

/**
* Get snapshot of runner's current files.
* Also prepares `package.json`'s `stackblitz.startCommand` with runner's commands.
*
* Note that file paths do not contain the leading `/`.
*/
takeSnapshot() {
const files: Record<string, string> = {};

// first add template files
for (const [filePath, value] of Object.entries(this._currentTemplate || {})) {
if (typeof value === 'string') {
files[filePath.slice(1)] = value;
}
}

// next overwrite with files from editor
for (const [filePath, value] of Object.entries(this._currentFiles || {})) {
if (typeof value === 'string') {
files[filePath.slice(1)] = value;
}
}

if (this._packageJsonContent) {
let packageJson;

try {
packageJson = JSON.parse(this._packageJsonContent);
} catch {}

// add start commands when missing
if (packageJson && !packageJson.stackblitz?.startCommand) {
const mainCommand = this._currentRunCommands?.mainCommand?.shellCommand;
const prepareCommands = (this._currentRunCommands?.prepareCommands || []).map((c) => c.shellCommand);
const startCommand = [...prepareCommands, mainCommand].filter(Boolean).join(' && ');

files[this._packageJsonPath.slice(1)] = JSON.stringify(
{ ...packageJson, stackblitz: { startCommand } },
null,
2,
);
}
}

return { files };
}

private async _runCommands(webcontainer: WebContainer, commands: Commands, signal: AbortSignal) {
const output = this._terminalStore.getOutputPanel();

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

return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
type: chapter
title: The second chapter in part 1
openInStackBlitz: true
---
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
type: chapter
title: The first chatper in part 2
openInStackBlitz: false
---
3 changes: 3 additions & 0 deletions packages/template/src/content/tutorial/meta.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ prepareCommands:
- ['npm install', 'Installing dependencies']
i18n:
partTemplate: ${title}
openInStackBlitz:
projectTitle: Example Title
projectDescription: Example Description
---
24 changes: 24 additions & 0 deletions packages/types/src/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,30 @@ export const webcontainerSchema = commandsSchema.extend({
.describe(
'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`.',
),
openInStackBlitz: z
.union([
// `false` for disabling the link
z.boolean(),

z.strictObject({
projectTitle: z.string().optional(),
projectDescription: z.string().optional(),
projectTemplate: z
.union([
z.literal('html'),
z.literal('node'),
z.literal('angular-cli'),
z.literal('create-react-app'),
z.literal('javascript'),
z.literal('polymer'),
z.literal('typescript'),
z.literal('vue'),
])
.optional(),
}),
])
.optional()
.describe('Display a link for opening current lesson in StackBlitz.'),
});

export const baseSchema = webcontainerSchema.extend({
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.